diff --git a/.github/workflows/build_all.yml b/.github/workflows/build_all.yml index 04b534cf103e..7303bc148cef 100644 --- a/.github/workflows/build_all.yml +++ b/.github/workflows/build_all.yml @@ -2,6 +2,7 @@ name: Build and pack DevExtreme npm packages on: pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] paths-ignore: - 'apps/**/*.md' push: @@ -17,9 +18,9 @@ on: env: NX_CLOUD_ACCESS_TOKEN: ${{ github.ref_name == github.event.repository.default_branch && secrets.NX_CLOUD_ACCESS_TOKEN || '' }} NX_SKIP_NX_CACHE: ${{ contains(github.event.pull_request.labels.*.name, 'skip-cache') && 'true' || 'false' }} - jobs: build: + if: ${{ !(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation')) }} runs-on: devextreme-shr2 steps: @@ -79,7 +80,15 @@ jobs: packages/sbom/dist/**/* retention-days: 1 + renovation-skip-build: + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') + runs-on: ubuntu-latest + name: build + steps: + - run: echo "Skipped build for renovation PR." + custom_bundles: + if: ${{ !(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation')) }} runs-on: devextreme-shr2 needs: build diff --git a/.github/workflows/default_workflow.yml b/.github/workflows/default_workflow.yml index 207fa3af505b..1545a636ef25 100644 --- a/.github/workflows/default_workflow.yml +++ b/.github/workflows/default_workflow.yml @@ -6,6 +6,7 @@ concurrency: on: pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] paths-ignore: - 'apps/**/*.md' push: @@ -16,7 +17,15 @@ env: NX_SKIP_NX_CACHE: ${{ contains(github.event.pull_request.labels.*.name, 'skip-cache') && 'true' || 'false' }} jobs: + renovation-skip-main: + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') + runs-on: ubuntu-latest + name: main + steps: + - run: echo "Skipped Default Workflow for renovation PR." + main: + if: ${{ !(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation')) }} name: main runs-on: devextreme-shr2 timeout-minutes: 30 diff --git a/.github/workflows/demos_unit_tests.yml b/.github/workflows/demos_unit_tests.yml index 8564bb414675..e5cc26aae4f5 100644 --- a/.github/workflows/demos_unit_tests.yml +++ b/.github/workflows/demos_unit_tests.yml @@ -6,6 +6,7 @@ concurrency: on: pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] paths-ignore: - 'apps/**/*.md' push: @@ -16,7 +17,15 @@ env: RUN_TESTS: true jobs: + renovation-skip-test: + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') + runs-on: ubuntu-latest + name: Run unit tests + steps: + - run: echo "Skipped Demos unit tests for renovation PR." + check-should-run: + if: ${{ !(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation')) }} name: Check if tests should run runs-on: devextreme-shr2 outputs: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 136ffce16736..3428d30ba5a9 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,6 +6,7 @@ concurrency: on: pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] paths-ignore: - 'apps/**/*.md' push: @@ -16,6 +17,7 @@ env: jobs: TS: + if: ${{ !(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation')) }} runs-on: devextreme-shr2 timeout-minutes: 60 steps: @@ -70,7 +72,22 @@ jobs: DEBUG: eslint:cli-engine run: pnpm exec nx lint + renovation-skip-TS: + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') + runs-on: ubuntu-latest + name: TS + steps: + - run: echo "Skipped Lint TS for renovation PR." + + renovation-skip-JS: + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') + runs-on: ubuntu-latest + name: JS + steps: + - run: echo "Skipped Lint JS for renovation PR." + JS: + if: ${{ !(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation')) }} runs-on: devextreme-shr2 timeout-minutes: 60 steps: @@ -148,7 +165,15 @@ jobs: working-directory: ./packages/devextreme run: pnpm exec nx lint-texts + renovation-skip-texts: + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') + runs-on: ubuntu-latest + name: texts + steps: + - run: echo "Skipped Lint texts for renovation PR." + component_exports: + if: ${{ !(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation')) }} runs-on: devextreme-shr2 timeout-minutes: 10 steps: @@ -194,7 +219,15 @@ jobs: exit 1 fi + renovation-skip-component-exports: + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') + runs-on: ubuntu-latest + name: component_exports + steps: + - run: echo "Skipped Lint component_exports for renovation PR." + wrappers: + if: ${{ !(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation')) }} runs-on: devextreme-shr2 timeout-minutes: 10 @@ -230,6 +263,13 @@ jobs: - name: Lint wrappers run: pnpm exec nx run-many -t lint -p devextreme-angular devextreme-react devextreme-vue + renovation-skip-wrappers: + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') + runs-on: ubuntu-latest + name: wrappers + steps: + - run: echo "Skipped Lint wrappers for renovation PR." + notify: runs-on: devextreme-shr2 name: Send notifications diff --git a/.github/workflows/playgrounds_tests.yml b/.github/workflows/playgrounds_tests.yml index 567f1eb9be74..0dd8ff42ac75 100644 --- a/.github/workflows/playgrounds_tests.yml +++ b/.github/workflows/playgrounds_tests.yml @@ -6,6 +6,7 @@ concurrency: on: pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] paths-ignore: - 'apps/**/*.md' push: @@ -16,6 +17,7 @@ env: jobs: build: + if: ${{ !(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation')) }} name: build strategy: fail-fast: false @@ -68,7 +70,23 @@ jobs: path: ./packages/devextreme/artifacts.zip retention-days: 1 + renovation-skip-test: + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') + runs-on: ubuntu-latest + name: ${{ matrix.ARGS.platform }} + strategy: + fail-fast: false + matrix: + ARGS: [ + { platform: "angular" }, + { platform: "react" }, + { platform: "vue" }, + ] + steps: + - run: echo "Skipped Playgrounds tests for renovation PR." + test: + if: ${{ !(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation')) }} name: ${{ matrix.ARGS.platform }} needs: [build] strategy: diff --git a/.github/workflows/qunit_tests.yml b/.github/workflows/qunit_tests.yml index 4262094e0964..4556b9a1dd57 100644 --- a/.github/workflows/qunit_tests.yml +++ b/.github/workflows/qunit_tests.yml @@ -6,6 +6,7 @@ concurrency: on: pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] paths-ignore: - 'apps/**/*.md' push: @@ -14,7 +15,7 @@ on: env: NX_SKIP_NX_CACHE: ${{ contains(github.event.pull_request.labels.*.name, 'skip-cache') && 'true' || 'false' }} - RUN_ALL_TESTS: true + RUN_ALL_TESTS: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') && 'false' || 'true' }} jobs: check-should-run-all: @@ -28,6 +29,8 @@ jobs: run: echo "should-run=${{ env.RUN_ALL_TESTS }}" >> $GITHUB_OUTPUT build: + needs: check-should-run-all + if: needs.check-should-run-all.outputs.should-run == 'true' runs-on: devextreme-shr2 name: Build timeout-minutes: 60 @@ -81,7 +84,8 @@ jobs: retention-days: 1 qunit-tests: - needs: build + needs: [check-should-run-all, build] + if: needs.check-should-run-all.outputs.should-run == 'true' runs-on: devextreme-shr2 name: Constel ${{ matrix.CONSTEL }}${{ matrix.TIMEZONE != '' && format('-{0}', matrix.TIMEZONE) || '' }}${{ matrix.CSP == 'false' && '-no-csp' || '' }} timeout-minutes: 20 @@ -129,6 +133,67 @@ jobs: useCsp: ${{ matrix.CSP }} maxWorkers: ${{ matrix.MAX_WORKERS || '' }} + renovation-skip-build: + needs: check-should-run-all + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') + runs-on: ubuntu-latest + name: Build + steps: + - run: echo "Skipped QUnit build for renovation PR." + + renovation-skip-qunit-tests: + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') + runs-on: ubuntu-latest + name: Constel ${{ matrix.CONSTEL }}${{ matrix.TIMEZONE != '' && format('-{0}', matrix.TIMEZONE) || '' }}${{ matrix.CSP == 'false' && '-no-csp' || '' }} + strategy: + fail-fast: false + matrix: + CONSTEL: [ + misc, + ui, + ui.editors, + ui.grid, + ui.scheduler, + ] + TIMEZONE: [''] + CSP: ['true'] + useJQuery: ['false'] + MAX_WORKERS: [''] + include: + - CONSTEL: export + MAX_WORKERS: '3' + useJQuery: 'false' + CSP: 'true' + - CONSTEL: ui.scheduler + TIMEZONE: US/Pacific + useJQuery: 'false' + - CONSTEL: misc + CSP: 'false' + useJQuery: 'true' + - CONSTEL: ui.widgets + CSP: 'false' + useJQuery: 'false' + MAX_WORKERS: '3' + steps: + - run: echo "Skipped QUnit tests for renovation PR." + + renovation-skip-qunit-shadow-dom: + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') + runs-on: ubuntu-latest + name: ${{ matrix.constel }}-shadow-dom + strategy: + fail-fast: false + matrix: + constel: [ + 'ui', + 'ui.widgets', + 'ui.editors', + 'ui.grid', + 'ui.scheduler', + ] + steps: + - run: echo "Skipped QUnit shadow DOM tests for renovation PR." + qunit-tests-shadow-dom: needs: [check-should-run-all, build] if: needs.check-should-run-all.outputs.should-run == 'true' diff --git a/.github/workflows/renovation.yml b/.github/workflows/renovation.yml index 908d72738dfb..3d8a9001ed69 100644 --- a/.github/workflows/renovation.yml +++ b/.github/workflows/renovation.yml @@ -6,6 +6,7 @@ concurrency: on: pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] paths-ignore: - 'apps/**/*.md' push: @@ -15,7 +16,15 @@ env: NX_SKIP_NX_CACHE: ${{ contains(github.event.pull_request.labels.*.name, 'skip-cache') && 'true' || 'false' }} jobs: + renovation-skip-jest-tests: + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') + runs-on: ubuntu-latest + name: jest-tests + steps: + - run: echo "Skipped Renovation jest-tests for renovation PR." + jest-tests: + if: ${{ !(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation')) }} runs-on: devextreme-shr2 timeout-minutes: 60 steps: diff --git a/.github/workflows/styles.yml b/.github/workflows/styles.yml index a023406d6c25..c022d2ff64d9 100644 --- a/.github/workflows/styles.yml +++ b/.github/workflows/styles.yml @@ -6,6 +6,7 @@ concurrency: on: pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] paths-ignore: - 'apps/**/*.md' push: @@ -15,7 +16,15 @@ env: NX_SKIP_NX_CACHE: ${{ contains(github.event.pull_request.labels.*.name, 'skip-cache') && 'true' || 'false' }} jobs: + renovation-skip-tests: + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') + runs-on: ubuntu-latest + name: Tests + steps: + - run: echo "Skipped Styles tests for renovation PR." + Tests: + if: ${{ !(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation')) }} runs-on: devextreme-shr2 timeout-minutes: 60 steps: diff --git a/.github/workflows/testcafe_tests.yml b/.github/workflows/testcafe_tests.yml index a5104397b8a3..35d0ac999b8d 100644 --- a/.github/workflows/testcafe_tests.yml +++ b/.github/workflows/testcafe_tests.yml @@ -6,6 +6,7 @@ concurrency: on: pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] paths-ignore: - 'apps/**/*.md' push: @@ -14,12 +15,12 @@ on: env: NX_SKIP_NX_CACHE: ${{ contains(github.event.pull_request.labels.*.name, 'skip-cache') && 'true' || 'false' }} - RUN_TESTS: true + RUN_TESTS: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') && 'false' || 'true' }} jobs: check-should-run: name: Check if tests should run - runs-on: devextreme-shr2 + runs-on: ubuntu-latest outputs: should-run: ${{ steps.check.outputs.should-run }} styles-changed: ${{ steps.changes.outputs.styles }} @@ -38,6 +39,45 @@ jobs: - 'packages/devextreme/js/**/themes/**' - 'e2e/testcafe-devextreme/helpers/accessibility/**' + renovation-skip-testcafe: + name: ${{ matrix.ARGS.name }}${{ !matrix.ARGS.theme && ' - fluent' || '' }} + needs: check-should-run + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') + strategy: + fail-fast: false + matrix: + ARGS: [ + { componentFolder: "accessibility", name: "accessibility" }, + { componentFolder: "accessibility", name: "accessibility - fluent.dark", theme: 'fluent.blue.dark', styleDependent: true }, + { componentFolder: "accessibility", name: "accessibility - material.light", theme: 'material.blue.light', styleDependent: true }, + { componentFolder: "accessibility", name: "accessibility - material.dark", theme: 'material.blue.dark', styleDependent: true }, + { componentFolder: "common", name: "common" }, + + { name: "generic", theme: 'generic.light' }, + { name: "material", theme: 'material.blue.light' }, + { name: "material - compact", theme: 'material.blue.light.compact' }, + + { componentFolder: "cardView", name: "cardView" }, + { componentFolder: "dataGrid", name: "dataGrid (1/4)", indices: "1/4", cache: true }, + { componentFolder: "dataGrid", name: "dataGrid (2/4)", indices: "2/4", cache: true }, + { componentFolder: "dataGrid", name: "dataGrid (3/4)", indices: "3/4", cache: true }, + { componentFolder: "dataGrid", name: "dataGrid (4/4)", indices: "4/4", cache: true }, + + { componentFolder: "editors", name: "editors" }, + { componentFolder: "navigation", name: "navigation" }, + + { 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/timezones", name: "scheduler / timezones (Europe/Berlin)", timezone: "Europe/Berlin" }, + { componentFolder: "scheduler/timezones", name: "scheduler / timezones (America/Los_Angeles)", timezone: "America/Los_Angeles" }, + ] + runs-on: ubuntu-latest + steps: + - run: echo "Skipped TestCafe component tests for renovation PR." + build: name: Build DevExtreme needs: check-should-run @@ -229,8 +269,8 @@ jobs: merge-artifacts: runs-on: devextreme-shr2 - needs: testcafe - if: ${{ always() }} + needs: [check-should-run, testcafe] + if: ${{ always() && needs.check-should-run.outputs.should-run == 'true' }} steps: - name: Merge screenshot artifacts diff --git a/.github/workflows/themebuilder_tests.yml b/.github/workflows/themebuilder_tests.yml index 60494a6ce5c3..15741991ae7e 100644 --- a/.github/workflows/themebuilder_tests.yml +++ b/.github/workflows/themebuilder_tests.yml @@ -6,6 +6,7 @@ concurrency: on: pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] paths-ignore: - 'apps/**/*.md' push: @@ -15,7 +16,15 @@ env: NX_SKIP_NX_CACHE: ${{ contains(github.event.pull_request.labels.*.name, 'skip-cache') && 'true' || 'false' }} jobs: + renovation-skip-test: + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') + runs-on: ubuntu-latest + name: test + steps: + - run: echo "Skipped Themebuilder and Styles tests for renovation PR." + test: + if: ${{ !(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation')) }} runs-on: devextreme-shr2 env: NODE_OPTIONS: --max-old-space-size=8192 diff --git a/.github/workflows/ts_declarations.yml b/.github/workflows/ts_declarations.yml index bd18e03754b0..396d7f6b709d 100644 --- a/.github/workflows/ts_declarations.yml +++ b/.github/workflows/ts_declarations.yml @@ -6,13 +6,29 @@ concurrency: on: pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] paths-ignore: - 'apps/**/*.md' push: branches: [26_1] jobs: + renovation-skip-check-ts-bundle: + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') + runs-on: ubuntu-latest + name: check-ts-bundle + steps: + - run: echo "Skipped TS Declarations for renovation PR." + + renovation-skip-validate-declarations: + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') + runs-on: ubuntu-latest + name: validate-declarations + steps: + - run: echo "Skipped TS Declarations for renovation PR." + check-ts-bundle: + if: ${{ !(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation')) }} runs-on: devextreme-shr2 timeout-minutes: 60 steps: @@ -62,6 +78,7 @@ jobs: fi validate-declarations: + if: ${{ !(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation')) }} runs-on: devextreme-shr2 timeout-minutes: 60 steps: diff --git a/.github/workflows/visual-tests-demos-playwright.yml b/.github/workflows/visual-tests-demos-playwright.yml new file mode 100644 index 000000000000..d87a409887df --- /dev/null +++ b/.github/workflows/visual-tests-demos-playwright.yml @@ -0,0 +1,1098 @@ +name: Demos Visual Tests Playwright + +# concurrency: + # group: wf-${{github.event.pull_request.number || github.sha}}-${{github.workflow}} + # cancel-in-progress: true + +on: + pull_request: + paths-ignore: + - 'apps/**/*.md' + push: + branches: [26_1] + workflow_dispatch: + +env: + NX_SKIP_NX_CACHE: true + +jobs: + get-changes: + runs-on: devextreme-shr2 + if: github.event_name == 'pull_request' + name: Get changed demos + timeout-minutes: 5 + outputs: + has-changed-demos: ${{ steps.check-changes.outputs.has-changed-demos }} + + steps: + - name: Get sources + uses: actions/checkout@v6 + + - name: Get changed files + uses: DevExpress/github-actions/get-changed-files@v1 + with: + gh-token: ${{ secrets.GITHUB_TOKEN }} + paths: 'apps/demos/Demos/**/*;apps/demos/testing/widgets/**/*' + output: apps/demos/changed-files.json + + - name: Display changed files + id: check-changes + run: | + HAS_CHANGED="false" + if [ -f "apps/demos/changed-files.json" ]; then + DEMO_COUNT=$(jq length apps/demos/changed-files.json) + echo "Found changed-files.json" + echo "Content of changed-files.json:" + cat apps/demos/changed-files.json + echo "Number of changed files: $DEMO_COUNT" + if [ "$DEMO_COUNT" -gt 0 ]; then + HAS_CHANGED="true" + fi + else + echo "changed-files.json not found" + fi + echo "has-changed-demos=${HAS_CHANGED}" >> $GITHUB_OUTPUT + + - name: Upload artifacts + uses: actions/upload-artifact@v7 + with: + name: changed-demos + path: apps/demos/changed-files.json + retention-days: 1 + + determine-framework-tests-scope: + runs-on: devextreme-shr2 + name: Determine scope for framework tests + needs: [get-changes] + if: | + always() && + (needs.get-changes.result == 'success' || needs.get-changes.result == 'skipped') + outputs: + framework-tests-scope: ${{ steps.determine.outputs.framework-tests-scope }} + + steps: + - name: Determine framework tests scope + id: determine + run: | + if [ "${{ github.event_name }}" != "pull_request" ] || [ "${{ contains(github.event.pull_request.labels.*.name, 'force all tests') }}" = "true" ]; then + echo "Framework tests scope: all demos" + echo "framework-tests-scope=all" >> $GITHUB_OUTPUT + elif [ "${{ needs.get-changes.outputs.has-changed-demos }}" = "true" ]; then + echo "Framework tests scope: changed demos" + echo "framework-tests-scope=changed" >> $GITHUB_OUTPUT + else + echo "Framework tests NOT needed" + echo "framework-tests-scope=none" >> $GITHUB_OUTPUT + fi + + build-devextreme: + needs: [determine-framework-tests-scope] + if: | + always() && + needs.determine-framework-tests-scope.result == 'success' + runs-on: devextreme-shr2 + name: Build DevExtreme + env: + NODE_OPTIONS: --max-old-space-size=8192 + timeout-minutes: 30 + + steps: + - name: Get sources + uses: actions/checkout@v6 + + - name: Use mise + uses: jdx/mise-action@v4 + + - name: Get pnpm store directory + shell: bash + run: echo "STORE_PATH=$(mise exec -- pnpm store path --silent)" >> $GITHUB_ENV + + - uses: actions/cache@v5 + name: Setup 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: mise exec -- pnpm install --frozen-lockfile + + - name: DevExtreme - Build + if: needs.determine-framework-tests-scope.outputs.framework-tests-scope == 'none' + shell: bash + run: | + mise exec -- pnpm exec nx build devextreme-scss + mise exec -- pnpm exec nx build devextreme -c testing + + - name: DevExtreme - Build all packages + if: needs.determine-framework-tests-scope.outputs.framework-tests-scope != 'none' + run: mise exec -- pnpm exec nx all:build-testing workflows + + - name: Zip artifacts + working-directory: ./packages/devextreme + run: 7z a -tzip -mx3 -mmt2 artifacts.zip artifacts ../devextreme-scss/scss/bundles + + - name: Upload build artifacts + uses: actions/upload-artifact@v7 + with: + name: devextreme-artifacts-jquery + path: ./packages/devextreme/artifacts.zip + retention-days: 1 + + - name: Move framework package artifacts + if: needs.determine-framework-tests-scope.outputs.framework-tests-scope != 'none' + run: | + mv ./packages/devextreme/artifacts/npm/devextreme/*.tgz ./devextreme-installer.tgz + mv ./packages/devextreme/artifacts/npm/devextreme-dist/*.tgz ./devextreme-dist-installer.tgz + mv ./packages/devextreme-angular/npm/dist/*.tgz ./devextreme-angular-installer.tgz + mv ./packages/devextreme-react/npm/*.tgz ./devextreme-react-installer.tgz + mv ./packages/devextreme-vue/npm/*.tgz ./devextreme-vue-installer.tgz + + - name: Upload framework package artifacts + if: needs.determine-framework-tests-scope.outputs.framework-tests-scope != 'none' + uses: actions/upload-artifact@v7 + with: + name: devextreme-sources + path: | + devextreme-installer.tgz + devextreme-dist-installer.tgz + devextreme-angular-installer.tgz + devextreme-react-installer.tgz + devextreme-vue-installer.tgz + retention-days: 1 + + build-demos: + runs-on: devextreme-shr2 + name: Build Demos Bundles + timeout-minutes: 30 + needs: [determine-framework-tests-scope, build-devextreme] + if: | + always() && + needs.determine-framework-tests-scope.result == 'success' && + needs.determine-framework-tests-scope.outputs.framework-tests-scope != 'none' && + needs.build-devextreme.result == 'success' + env: + NODE_OPTIONS: --max-old-space-size=8192 + + steps: + - name: Get sources + uses: actions/checkout@v6 + + - name: Use mise + uses: jdx/mise-action@v4 + + - name: Download devextreme sources + uses: actions/download-artifact@v8 + with: + name: devextreme-sources + + - name: Get pnpm store directory + shell: bash + run: echo "STORE_PATH=$(mise exec -- 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: mise exec -- pnpm install --frozen-lockfile + + - name: Install tgz + working-directory: apps/demos + run: mise exec -- pnpm add --ignore-workspace --allow-build=core-js --allow-build=inferno devextreme-aspnet-data@5.1.0 devextreme-aspnet-data-nojquery@5.1.0 ../../devextreme-installer.tgz ../../devextreme-dist-installer.tgz ../../devextreme-react-installer.tgz ../../devextreme-vue-installer.tgz ../../devextreme-angular-installer.tgz && rm -f pnpm-workspace.yaml pnpm-lock.yaml + + - name: Prepare bundles + working-directory: apps/demos + run: mise exec -- pnpm exec nx prepare-bundles + + - name: Demos - Run tsc + working-directory: apps/demos + run: mise exec -- pnpm exec tsc --noEmit + + - name: Upload demos bundles + uses: actions/upload-artifact@v7 + with: + name: devextreme-bundles + path: | + apps/demos/bundles/ + retention-days: 1 + + common-screenshots: + needs: build-devextreme + strategy: + fail-fast: false + matrix: + CONSTEL: [jquery(1/3), jquery(2/3), jquery(3/3)] + THEME: ['fluent.blue.light', 'material.blue.light'] + + runs-on: devextreme-shr2 + name: ${{ matrix.CONSTEL }}-common-screenshots-${{ matrix.THEME }} + timeout-minutes: 60 + + steps: + - name: Get sources + uses: actions/checkout@v6 + + - name: Download artifacts + uses: actions/download-artifact@v8 + with: + name: devextreme-artifacts-jquery + 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: '145.0.7632.67' + + - name: Use mise + uses: jdx/mise-action@v4 + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(mise exec -- 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: mise exec -- pnpm install --frozen-lockfile + + - name: Set Roboto font directory + if: contains(matrix.THEME, 'material') + run: echo "ROBOTO_FONT_DIR=${HOME}/.local/share/fonts/roboto" >> $GITHUB_ENV + + - name: Cache Roboto font + if: contains(matrix.THEME, 'material') + id: cache-roboto + uses: actions/cache@v5 + with: + path: ${{ env.ROBOTO_FONT_DIR }} + key: roboto-font-${{ runner.os }}-8b0a1d0f5983c89bc2b93f1b5fb55f9e252744b5 + + - name: Install Roboto font for Material theme + if: contains(matrix.THEME, 'material') && steps.cache-roboto.outputs.cache-hit != 'true' + env: + GOOGLE_FONTS_COMMIT: 8b0a1d0f5983c89bc2b93f1b5fb55f9e252744b5 + ROBOTO_SHA256: d7598e12c5dbef095ff8272cfc55da0250bd07fbdecbac8a530b9b277872a134 + run: | + echo "Installing Roboto font from google/fonts (pinned commit)..." + mkdir -p "$ROBOTO_FONT_DIR" + BASE_URL="https://raw.githubusercontent.com/google/fonts/${GOOGLE_FONTS_COMMIT}/ofl/roboto" + + curl -fsSL "${BASE_URL}/Roboto%5Bwdth%2Cwght%5D.ttf" -o /tmp/Roboto.ttf + echo "${ROBOTO_SHA256} /tmp/Roboto.ttf" | sha256sum -c - + + mv /tmp/Roboto.ttf "$ROBOTO_FONT_DIR/" + echo "Roboto font installed" + + - name: Refresh font cache + if: contains(matrix.THEME, 'material') + run: fc-cache -f > /dev/null 2>&1 + + - name: Run Web Server + shell: bash + run: | + if command -v python3 > /dev/null; then + python3 -m http.server 8080 & + else + python -m http.server 8080 & + fi + + - name: Set Chrome flags + id: chrome-flags + shell: bash + run: | + BASE_FLAGS="chrome:headless --window-size=1200,800 --disable-gpu --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" + + if [[ "${{ matrix.THEME }}" != *"material"* ]]; then + BASE_FLAGS="$BASE_FLAGS --font-render-hinting=none --disable-font-subpixel-positioning" + fi + + echo "flags=$BASE_FLAGS" >> $GITHUB_OUTPUT + + - name: Run Playwright common screenshots + shell: bash + working-directory: apps/demos + env: + NODE_OPTIONS: --max-old-space-size=8192 + BROWSERS: ${{ steps.chrome-flags.outputs.flags }} + CONCURRENCY: 4 + TCQUARANTINE: true + CONSTEL: ${{ matrix.CONSTEL }} + THEME: ${{ matrix.THEME }} + CI_ENV: true + # Use Chrome's legacy CDP screenshot surface so screenshots match the + # etalons generated under TestCafe (which doesn't enable Playwright's + # CDPScreenshotNewSurface feature). Without this, Material text in + # particular renders with visibly different antialiasing/glyph metrics. + PLAYWRIGHT_LEGACY_SCREENSHOT: '1' + run: mise exec -- pnpm exec nx test-playwright-common-screenshots + + - name: Run Playwright widget screenshots + if: ${{ !cancelled() }} + shell: bash + working-directory: apps/demos + env: + NODE_OPTIONS: --max-old-space-size=8192 + BROWSERS: ${{ steps.chrome-flags.outputs.flags }} + CONCURRENCY: 4 + TCQUARANTINE: true + CONSTEL: ${{ matrix.CONSTEL }} + THEME: ${{ matrix.THEME }} + CI_ENV: true + PLAYWRIGHT_LEGACY_SCREENSHOT: '1' + run: mise exec -- pnpm exec nx test-playwright-widgets-screenshots + + - name: Sanitize job name + if: ${{ failure() }} + run: echo "JOB_NAME=$(echo "${{ matrix.CONSTEL }}-${{ matrix.THEME }}" | tr '/' '-')" >> $GITHUB_ENV + + - name: Copy screenshots artifacts + if: ${{ failure() }} + uses: actions/upload-artifact@v7 + with: + name: playwright-screenshots-${{ env.JOB_NAME }} + path: ${{ github.workspace }}/apps/demos/testing/artifacts/compared-screenshots/**/* + if-no-files-found: ignore + + - name: Copy Playwright report + if: ${{ failure() }} + uses: actions/upload-artifact@v7 + with: + name: playwright-report-${{ env.JOB_NAME }} + path: ${{ github.workspace }}/apps/demos/testing/artifacts/playwright-report/**/* + if-no-files-found: ignore + + common-csp: + needs: build-devextreme + strategy: + fail-fast: false + matrix: + CONSTEL: [jquery] + THEME: ['fluent.blue.light'] + + runs-on: devextreme-shr2 + name: ${{ matrix.CONSTEL }}-common-csp-${{ matrix.THEME }} + timeout-minutes: 60 + + steps: + - name: Get sources + uses: actions/checkout@v6 + + - name: Download artifacts + uses: actions/download-artifact@v8 + with: + name: devextreme-artifacts-jquery + 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: '145.0.7632.67' + + - name: Use mise + uses: jdx/mise-action@v4 + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(mise exec -- 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: mise exec -- pnpm install --frozen-lockfile + + - name: Run CSP server + shell: bash + working-directory: apps/demos + run: mise exec -- node utils/server/csp-server.js 8080 & + + - name: Set Chrome flags + id: chrome-flags + shell: bash + run: | + echo "flags=chrome:headless --window-size=1200,800 --disable-gpu --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" >> $GITHUB_OUTPUT + + - name: Run Playwright common CSP + shell: bash + working-directory: apps/demos + env: + NODE_OPTIONS: --max-old-space-size=8192 + BROWSERS: ${{ steps.chrome-flags.outputs.flags }} + CONCURRENCY: 4 + TCQUARANTINE: true + CONSTEL: ${{ matrix.CONSTEL }} + THEME: ${{ matrix.THEME }} + CI_ENV: true + run: mise exec -- pnpm exec nx test-playwright-common-csp + + - name: Show CSP violations summary + if: ${{ always() }} + shell: bash + working-directory: apps/demos + run: | + if [ -f csp-reports/csp-violations.jsonl ]; then + mise exec -- node utils/server/csp-report-summary.js >> $GITHUB_STEP_SUMMARY + else + echo "No CSP violations detected." >> $GITHUB_STEP_SUMMARY + fi + + - name: Upload CSP violations + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: playwright-csp-violations-${{ matrix.CONSTEL }}-${{ matrix.THEME }} + path: ${{ github.workspace }}/apps/demos/csp-reports/csp-violations.jsonl + if-no-files-found: ignore + + - name: Copy Playwright report + if: ${{ failure() }} + uses: actions/upload-artifact@v7 + with: + name: playwright-csp-report-${{ matrix.CONSTEL }}-${{ matrix.THEME }} + path: ${{ github.workspace }}/apps/demos/testing/artifacts/playwright-report/**/* + if-no-files-found: ignore + + common-accessibility: + needs: [determine-framework-tests-scope, build-devextreme] + if: | + always() && + needs.determine-framework-tests-scope.result == 'success' && + needs.build-devextreme.result == 'success' + strategy: + fail-fast: false + matrix: + CONSTEL: [jquery] + THEME: ['material.blue.light', 'fluent.blue.light', 'material.blue.dark', 'fluent.blue.dark'] + + runs-on: devextreme-shr2 + name: ${{ matrix.CONSTEL }}-accessibility-${{ matrix.THEME }} + timeout-minutes: 60 + + steps: + - name: Get sources + uses: actions/checkout@v6 + + - name: Download artifacts + uses: actions/download-artifact@v8 + with: + name: devextreme-artifacts-jquery + 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: '145.0.7632.67' + + - name: Use mise + uses: jdx/mise-action@v4 + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(mise exec -- 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: mise exec -- pnpm install --frozen-lockfile + + - name: Set Roboto font directory + if: contains(matrix.THEME, 'material') + run: echo "ROBOTO_FONT_DIR=${HOME}/.local/share/fonts/roboto" >> $GITHUB_ENV + + - name: Cache Roboto font + if: contains(matrix.THEME, 'material') + id: cache-roboto + uses: actions/cache@v5 + with: + path: ${{ env.ROBOTO_FONT_DIR }} + key: roboto-font-${{ runner.os }}-8b0a1d0f5983c89bc2b93f1b5fb55f9e252744b5 + + - name: Install Roboto font for Material theme + if: contains(matrix.THEME, 'material') && steps.cache-roboto.outputs.cache-hit != 'true' + env: + GOOGLE_FONTS_COMMIT: 8b0a1d0f5983c89bc2b93f1b5fb55f9e252744b5 + ROBOTO_SHA256: d7598e12c5dbef095ff8272cfc55da0250bd07fbdecbac8a530b9b277872a134 + run: | + echo "Installing Roboto font from google/fonts (pinned commit)..." + mkdir -p "$ROBOTO_FONT_DIR" + BASE_URL="https://raw.githubusercontent.com/google/fonts/${GOOGLE_FONTS_COMMIT}/ofl/roboto" + + curl -fsSL "${BASE_URL}/Roboto%5Bwdth%2Cwght%5D.ttf" -o /tmp/Roboto.ttf + echo "${ROBOTO_SHA256} /tmp/Roboto.ttf" | sha256sum -c - + + mv /tmp/Roboto.ttf "$ROBOTO_FONT_DIR/" + echo "Roboto font installed" + + - name: Refresh font cache + if: contains(matrix.THEME, 'material') + run: fc-cache -f > /dev/null 2>&1 + + - name: Run Web Server + shell: bash + run: | + if command -v python3 > /dev/null; then + python3 -m http.server 8080 & + else + python -m http.server 8080 & + fi + + - name: Set Chrome flags + id: chrome-flags + shell: bash + run: | + BASE_FLAGS="chrome:headless --window-size=1200,800 --disable-gpu --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" + + if [[ "${{ matrix.THEME }}" != *"material"* ]]; then + BASE_FLAGS="$BASE_FLAGS --font-render-hinting=none --disable-font-subpixel-positioning" + fi + + echo "flags=$BASE_FLAGS" >> $GITHUB_OUTPUT + + - name: Run Playwright common accessibility + if: ${{ !contains(matrix.THEME, 'dark') || needs.determine-framework-tests-scope.outputs.framework-tests-scope != 'none' }} + shell: bash + working-directory: apps/demos + env: + NODE_OPTIONS: --max-old-space-size=8192 + BROWSERS: ${{ steps.chrome-flags.outputs.flags }} + CONCURRENCY: 4 + TCQUARANTINE: true + STRATEGY: accessibility + CONSTEL: ${{ matrix.CONSTEL }} + THEME: ${{ matrix.THEME }} + CI_ENV: true + PLAYWRIGHT_LEGACY_SCREENSHOT: '1' + run: mise exec -- pnpm exec nx test-playwright-common-accessibility + + - name: Sanitize job name + if: ${{ always() }} + run: echo "JOB_NAME=$(echo "${{ matrix.CONSTEL }}-${{ matrix.THEME }}" | tr '/' '-')" >> $GITHUB_ENV + + - name: Show accessibility warnings + if: ${{ always() && (!contains(matrix.THEME, 'dark') || needs.determine-framework-tests-scope.outputs.framework-tests-scope != 'none') }} + shell: bash + working-directory: apps/demos + run: | + if [ -d testing/artifacts/axe-reports ]; then + REPORT_COUNT=$(find testing/artifacts/axe-reports -name '*.md' | wc -l | tr -d ' ') + else + REPORT_COUNT=0 + fi + + if [ "$REPORT_COUNT" -gt 0 ]; then + echo "::warning ::Axe reports: ${REPORT_COUNT} demos have accessibility violations" + else + echo "No accessibility reports found." + fi + + - name: Copy accessibility reports + if: ${{ always() && (!contains(matrix.THEME, 'dark') || needs.determine-framework-tests-scope.outputs.framework-tests-scope != 'none') }} + uses: actions/upload-artifact@v7 + with: + name: playwright-accessibility-reports-${{ env.JOB_NAME }} + path: ${{ github.workspace }}/apps/demos/testing/artifacts/axe-reports/**/* + if-no-files-found: ignore + + - name: Copy Playwright report + if: ${{ failure() }} + uses: actions/upload-artifact@v7 + with: + name: playwright-accessibility-report-${{ env.JOB_NAME }} + path: ${{ github.workspace }}/apps/demos/testing/artifacts/playwright-report/**/* + if-no-files-found: ignore + + common-screenshots-frameworks-all: + needs: [determine-framework-tests-scope, build-demos] + if: | + always() && + needs.determine-framework-tests-scope.result == 'success' && + needs.determine-framework-tests-scope.outputs.framework-tests-scope == 'all' && + needs.build-demos.result == 'success' + strategy: + fail-fast: false + matrix: + CONSTEL: [ + react(1/3), + react(2/3), + react(3/3), + vue(1/5), + vue(2/5), + vue(3/5), + vue(4/5), + vue(5/5), + angular(1/10), + angular(2/10), + angular(3/10), + angular(4/10), + angular(5/10), + angular(6/10), + angular(7/10), + angular(8/10), + angular(9/10), + angular(10/10), + ] + THEME: ['fluent.blue.light'] + + runs-on: devextreme-shr2 + name: ${{ matrix.CONSTEL }}-common-screenshots-${{ matrix.THEME }} + timeout-minutes: 60 + + steps: + - name: Get sources + uses: actions/checkout@v6 + + - name: Setup Chrome + uses: ./.github/actions/setup-chrome + with: + chrome-version: '145.0.7632.67' + + - name: Use mise + uses: jdx/mise-action@v4 + + - name: Download devextreme sources + uses: actions/download-artifact@v8 + with: + name: devextreme-sources + + - name: Get pnpm store directory + shell: bash + run: echo "STORE_PATH=$(mise exec -- 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: mise exec -- pnpm install --frozen-lockfile + + - name: Install tgz + working-directory: apps/demos + run: mise exec -- pnpm add --ignore-workspace --allow-build=core-js --allow-build=inferno devextreme-aspnet-data@5.1.0 devextreme-aspnet-data-nojquery@5.1.0 ../../devextreme-installer.tgz ../../devextreme-dist-installer.tgz ../../devextreme-react-installer.tgz ../../devextreme-vue-installer.tgz ../../devextreme-angular-installer.tgz && rm -f pnpm-workspace.yaml pnpm-lock.yaml + + - name: Prepare JS + working-directory: apps/demos + run: mise exec -- pnpm run prepare-js + + - name: Update bundles config + working-directory: apps/demos + run: mise exec -- pnpm exec gulp update-config + + - name: Create bundles dir + run: mkdir -p apps/demos/bundles + + - name: Download bundles artifacts + uses: actions/download-artifact@v8 + with: + name: devextreme-bundles + path: apps/demos/bundles + + - name: Run Web Server + shell: bash + run: | + if command -v python3 > /dev/null; then + python3 -m http.server 8080 & + else + python -m http.server 8080 & + fi + + - name: Set concurrency based on framework + id: set-concurrency + shell: bash + run: | + if [[ "${{ matrix.CONSTEL }}" == react* ]]; then + echo "concurrency=4" >> $GITHUB_OUTPUT + elif [[ "${{ matrix.CONSTEL }}" == vue* ]]; then + echo "concurrency=1" >> $GITHUB_OUTPUT + elif [[ "${{ matrix.CONSTEL }}" == angular* ]]; then + echo "concurrency=2" >> $GITHUB_OUTPUT + else + echo "concurrency=2" >> $GITHUB_OUTPUT + fi + + - name: Run Playwright common screenshots + shell: bash + working-directory: apps/demos + env: + NODE_OPTIONS: --max-old-space-size=8192 + BROWSERS: chrome:headless --window-size=1200,800 --disable-gpu --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: ${{ steps.set-concurrency.outputs.concurrency }} + TCQUARANTINE: true + CONSTEL: ${{ matrix.CONSTEL }} + THEME: ${{ matrix.THEME }} + CI_ENV: true + run: mise exec -- pnpm exec nx test-playwright-common-screenshots + + - name: Run Playwright widget screenshots + if: ${{ !cancelled() }} + shell: bash + working-directory: apps/demos + env: + NODE_OPTIONS: --max-old-space-size=8192 + BROWSERS: chrome:headless --window-size=1200,800 --disable-gpu --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: ${{ steps.set-concurrency.outputs.concurrency }} + TCQUARANTINE: true + CONSTEL: ${{ matrix.CONSTEL }} + THEME: ${{ matrix.THEME }} + CI_ENV: true + run: mise exec -- pnpm exec nx test-playwright-widgets-screenshots + + - name: Sanitize job name + if: ${{ always() }} + run: echo "JOB_NAME=$(echo "${{ matrix.CONSTEL }}-${{ matrix.THEME }}" | tr '/' '-')" >> $GITHUB_ENV + + - name: Run CSP server + shell: bash + working-directory: apps/demos + run: | + rm -rf csp-reports + mise exec -- node utils/server/csp-server.js 8081 & + + - name: Run Playwright common CSP + shell: bash + working-directory: apps/demos + env: + NODE_OPTIONS: --max-old-space-size=8192 + PLAYWRIGHT_DEMOS_BASE_URL: http://127.0.0.1:8081 + BROWSERS: chrome:headless --window-size=1200,800 --disable-gpu --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: ${{ steps.set-concurrency.outputs.concurrency }} + TCQUARANTINE: true + STRATEGY: csp + CONSTEL: ${{ matrix.CONSTEL }} + THEME: ${{ matrix.THEME }} + CI_ENV: true + run: mise exec -- pnpm exec nx test-playwright-common-csp + + - name: Show CSP violations summary + if: ${{ always() }} + shell: bash + working-directory: apps/demos + run: | + if [ -f csp-reports/csp-violations.jsonl ]; then + mise exec -- node utils/server/csp-report-summary.js >> $GITHUB_STEP_SUMMARY + else + echo "No CSP violations detected." >> $GITHUB_STEP_SUMMARY + fi + + - name: Upload CSP violations + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: playwright-csp-violations-${{ env.JOB_NAME }} + path: ${{ github.workspace }}/apps/demos/csp-reports/csp-violations.jsonl + if-no-files-found: ignore + + - name: Run Playwright common accessibility + shell: bash + working-directory: apps/demos + env: + NODE_OPTIONS: --max-old-space-size=8192 + BROWSERS: chrome:headless --window-size=1200,800 --disable-gpu --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: ${{ steps.set-concurrency.outputs.concurrency }} + TCQUARANTINE: true + STRATEGY: accessibility + CONSTEL: ${{ matrix.CONSTEL }} + THEME: ${{ matrix.THEME }} + CI_ENV: true + PLAYWRIGHT_LEGACY_SCREENSHOT: '1' + run: mise exec -- pnpm exec nx test-playwright-common-accessibility + + - name: Show accessibility warnings + if: ${{ always() }} + shell: bash + working-directory: apps/demos + run: | + if [ -d testing/artifacts/axe-reports ]; then + REPORT_COUNT=$(find testing/artifacts/axe-reports -name '*.md' | wc -l | tr -d ' ') + else + REPORT_COUNT=0 + fi + + if [ "$REPORT_COUNT" -gt 0 ]; then + echo "::warning ::Axe reports: ${REPORT_COUNT} demos have accessibility violations" + else + echo "No accessibility reports found." + fi + + - name: Copy accessibility reports + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: playwright-accessibility-reports-${{ env.JOB_NAME }} + path: ${{ github.workspace }}/apps/demos/testing/artifacts/axe-reports/**/* + if-no-files-found: ignore + + - name: Copy screenshots artifacts + if: ${{ failure() }} + uses: actions/upload-artifact@v7 + with: + name: playwright-screenshots-${{ env.JOB_NAME }} + path: ${{ github.workspace }}/apps/demos/testing/artifacts/compared-screenshots/**/* + if-no-files-found: ignore + + - name: Copy Playwright report + if: ${{ failure() }} + uses: actions/upload-artifact@v7 + with: + name: playwright-report-${{ env.JOB_NAME }} + path: ${{ github.workspace }}/apps/demos/testing/artifacts/playwright-report/**/* + if-no-files-found: ignore + + common-screenshots-frameworks-changed: + needs: [determine-framework-tests-scope, build-demos] + if: | + always() && + needs.determine-framework-tests-scope.result == 'success' && + needs.determine-framework-tests-scope.outputs.framework-tests-scope == 'changed' && + needs.build-demos.result == 'success' + strategy: + fail-fast: false + matrix: + CONSTEL: [react, vue, angular] + THEME: ['fluent.blue.light'] + + runs-on: devextreme-shr2 + name: ${{ matrix.CONSTEL }}-common-screenshots-${{ matrix.THEME }} + timeout-minutes: 60 + + steps: + - name: Get sources + uses: actions/checkout@v6 + + - name: Setup Chrome + uses: ./.github/actions/setup-chrome + with: + chrome-version: '145.0.7632.67' + + - name: Use mise + uses: jdx/mise-action@v4 + + - name: Download devextreme sources + uses: actions/download-artifact@v8 + with: + name: devextreme-sources + + - name: Download changed demos + uses: actions/download-artifact@v8 + with: + name: changed-demos + path: apps/demos + continue-on-error: true + + - name: Get pnpm store directory + shell: bash + run: echo "STORE_PATH=$(mise exec -- 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: mise exec -- pnpm install --frozen-lockfile + + - name: Install tgz + working-directory: apps/demos + run: mise exec -- pnpm add --ignore-workspace --allow-build=core-js --allow-build=inferno devextreme-aspnet-data@5.1.0 devextreme-aspnet-data-nojquery@5.1.0 ../../devextreme-installer.tgz ../../devextreme-dist-installer.tgz ../../devextreme-react-installer.tgz ../../devextreme-vue-installer.tgz ../../devextreme-angular-installer.tgz && rm -f pnpm-workspace.yaml pnpm-lock.yaml + + - name: Prepare JS + working-directory: apps/demos + run: mise exec -- pnpm run prepare-js + + - name: Update bundles config + working-directory: apps/demos + run: mise exec -- pnpm exec gulp update-config + + - name: Create bundles dir + run: mkdir -p apps/demos/bundles + + - name: Download bundles artifacts + uses: actions/download-artifact@v8 + with: + name: devextreme-bundles + path: apps/demos/bundles + + - name: Run Web Server + shell: bash + run: | + if command -v python3 > /dev/null; then + python3 -m http.server 8080 & + else + python -m http.server 8080 & + fi + + - name: Run Playwright common screenshots + shell: bash + working-directory: apps/demos + env: + NODE_OPTIONS: --max-old-space-size=8192 + CHANGEDFILEINFOSPATH: changed-files.json + BROWSERS: chrome:headless --window-size=1200,800 --disable-gpu --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: 1 + TCQUARANTINE: true + CONSTEL: ${{ matrix.CONSTEL }} + THEME: ${{ matrix.THEME }} + CI_ENV: true + run: mise exec -- pnpm exec nx test-playwright-common-screenshots -- --pass-with-no-tests + + - name: Run Playwright widget screenshots + if: ${{ !cancelled() }} + shell: bash + working-directory: apps/demos + env: + NODE_OPTIONS: --max-old-space-size=8192 + CHANGEDFILEINFOSPATH: changed-files.json + BROWSERS: chrome:headless --window-size=1200,800 --disable-gpu --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: 1 + TCQUARANTINE: true + CONSTEL: ${{ matrix.CONSTEL }} + THEME: ${{ matrix.THEME }} + CI_ENV: true + run: mise exec -- pnpm exec nx test-playwright-widgets-screenshots -- --pass-with-no-tests + + - name: Sanitize job name + if: ${{ always() }} + run: echo "JOB_NAME=$(echo "${{ matrix.CONSTEL }}-${{ matrix.THEME }}" | tr '/' '-')" >> $GITHUB_ENV + + - name: Run CSP server + shell: bash + working-directory: apps/demos + run: | + rm -rf csp-reports + mise exec -- node utils/server/csp-server.js 8081 & + + - name: Run Playwright common CSP + shell: bash + working-directory: apps/demos + env: + NODE_OPTIONS: --max-old-space-size=8192 + CHANGEDFILEINFOSPATH: changed-files.json + PLAYWRIGHT_DEMOS_BASE_URL: http://127.0.0.1:8081 + BROWSERS: chrome:headless --window-size=1200,800 --disable-gpu --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: 1 + TCQUARANTINE: true + STRATEGY: csp + CONSTEL: ${{ matrix.CONSTEL }} + THEME: ${{ matrix.THEME }} + CI_ENV: true + run: mise exec -- pnpm exec nx test-playwright-common-csp -- --pass-with-no-tests + + - name: Show CSP violations summary + if: ${{ always() }} + shell: bash + working-directory: apps/demos + run: | + if [ -f csp-reports/csp-violations.jsonl ]; then + mise exec -- node utils/server/csp-report-summary.js >> $GITHUB_STEP_SUMMARY + else + echo "No CSP violations detected." >> $GITHUB_STEP_SUMMARY + fi + + - name: Upload CSP violations + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: playwright-csp-violations-${{ env.JOB_NAME }} + path: ${{ github.workspace }}/apps/demos/csp-reports/csp-violations.jsonl + if-no-files-found: ignore + + - name: Run Playwright common accessibility + shell: bash + working-directory: apps/demos + env: + NODE_OPTIONS: --max-old-space-size=8192 + CHANGEDFILEINFOSPATH: changed-files.json + BROWSERS: chrome:headless --window-size=1200,800 --disable-gpu --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: 1 + TCQUARANTINE: true + STRATEGY: accessibility + CONSTEL: ${{ matrix.CONSTEL }} + THEME: ${{ matrix.THEME }} + CI_ENV: true + PLAYWRIGHT_LEGACY_SCREENSHOT: '1' + run: mise exec -- pnpm exec nx test-playwright-common-accessibility -- --pass-with-no-tests + + - name: Show accessibility warnings + if: ${{ always() }} + shell: bash + working-directory: apps/demos + run: | + if [ -d testing/artifacts/axe-reports ]; then + REPORT_COUNT=$(find testing/artifacts/axe-reports -name '*.md' | wc -l | tr -d ' ') + else + REPORT_COUNT=0 + fi + + if [ "$REPORT_COUNT" -gt 0 ]; then + echo "::warning ::Axe reports: ${REPORT_COUNT} demos have accessibility violations" + else + echo "No accessibility reports found." + fi + + - name: Copy accessibility reports + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: playwright-accessibility-reports-${{ env.JOB_NAME }} + path: ${{ github.workspace }}/apps/demos/testing/artifacts/axe-reports/**/* + if-no-files-found: ignore + + - name: Copy screenshots artifacts + if: ${{ failure() }} + uses: actions/upload-artifact@v7 + with: + name: playwright-screenshots-${{ env.JOB_NAME }} + path: ${{ github.workspace }}/apps/demos/testing/artifacts/compared-screenshots/**/* + if-no-files-found: ignore + + - name: Copy Playwright report + if: ${{ failure() }} + uses: actions/upload-artifact@v7 + with: + name: playwright-report-${{ env.JOB_NAME }} + path: ${{ github.workspace }}/apps/demos/testing/artifacts/playwright-report/**/* + if-no-files-found: ignore diff --git a/.github/workflows/visual-tests-demos.yml b/.github/workflows/visual-tests-demos.yml index df7600017883..91fd1556084b 100644 --- a/.github/workflows/visual-tests-demos.yml +++ b/.github/workflows/visual-tests-demos.yml @@ -6,6 +6,7 @@ concurrency: on: pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] paths-ignore: - 'apps/**/*.md' push: @@ -14,12 +15,12 @@ on: env: NX_SKIP_NX_CACHE: ${{ contains(github.event.pull_request.labels.*.name, 'skip-cache') && 'true' || 'false' }} - RUN_TESTS: true + RUN_TESTS: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') && 'false' || 'true' }} jobs: check-should-run: name: Check if tests should run - runs-on: devextreme-shr2 + runs-on: ubuntu-latest outputs: should-run: ${{ steps.check.outputs.should-run }} steps: @@ -27,6 +28,41 @@ jobs: id: check run: echo "should-run=${{ env.RUN_TESTS }}" >> $GITHUB_OUTPUT + renovation-skip-build-devextreme: + name: Build DevExtreme + needs: check-should-run + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') + runs-on: ubuntu-latest + steps: + - run: echo "Skipped legacy TestCafe demos build for renovation PR." + + renovation-skip-testcafe-jquery: + name: ${{ matrix.CONSTEL }}-${{ matrix.STRATEGY }}-${{ matrix.THEME }} + needs: check-should-run + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') + strategy: + fail-fast: false + matrix: + STRATEGY: [screenshots, accessibility] + THEME: ['material.blue.light', 'fluent.blue.light', 'material.blue.dark', 'fluent.blue.dark'] + CONSTEL: [jquery(1/3), jquery(2/3), jquery(3/3), jquery] + exclude: + - STRATEGY: accessibility + CONSTEL: jquery(1/3) + - STRATEGY: accessibility + CONSTEL: jquery(2/3) + - STRATEGY: accessibility + CONSTEL: jquery(3/3) + - STRATEGY: screenshots + CONSTEL: jquery + - STRATEGY: screenshots + THEME: 'material.blue.dark' + - STRATEGY: screenshots + THEME: 'fluent.blue.dark' + runs-on: ubuntu-latest + steps: + - run: echo "Skipped legacy TestCafe demos jQuery visual tests for renovation PR." + get-changes: runs-on: devextreme-shr2 needs: check-should-run diff --git a/.github/workflows/wrapper_tests.yml b/.github/workflows/wrapper_tests.yml index b4d47418703c..25684a3b73ff 100644 --- a/.github/workflows/wrapper_tests.yml +++ b/.github/workflows/wrapper_tests.yml @@ -2,6 +2,7 @@ name: Wrappers Tests on: pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] paths-ignore: - 'apps/**/*.md' push: @@ -12,6 +13,7 @@ env: jobs: build: + if: ${{ !(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation')) }} runs-on: devextreme-shr2 timeout-minutes: 30 @@ -48,6 +50,7 @@ jobs: run: pnpm exec nx build devextreme -c testing check-regenerate: + if: ${{ !(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation')) }} runs-on: devextreme-shr2 timeout-minutes: 10 @@ -95,7 +98,19 @@ jobs: exit 1 fi + renovation-skip-test: + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') + runs-on: ubuntu-latest + name: test (${{ matrix.framework }}) + strategy: + fail-fast: false + matrix: + framework: [angular, react, vue] + steps: + - run: echo "Skipped Wrappers Tests for renovation PR." + test: + if: ${{ !(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation')) }} timeout-minutes: 30 needs: build runs-on: devextreme-shr2 diff --git a/.github/workflows/wrapper_tests_e2e.yml b/.github/workflows/wrapper_tests_e2e.yml index 0b7556ff4829..9d26471a1d82 100644 --- a/.github/workflows/wrapper_tests_e2e.yml +++ b/.github/workflows/wrapper_tests_e2e.yml @@ -6,6 +6,7 @@ concurrency: on: pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] paths-ignore: - 'apps/**/*.md' push: @@ -16,7 +17,15 @@ env: NX_SKIP_NX_CACHE: ${{ contains(github.event.pull_request.labels.*.name, 'skip-cache') && 'true' || 'false' }} jobs: + renovation-skip-build-packages: + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation') + runs-on: ubuntu-latest + name: Build DevExtreme and Wrappers + steps: + - run: echo "Skipped Wrappers E2E build for renovation PR." + build-packages: + if: ${{ !(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation')) }} runs-on: devextreme-shr2 name: Build DevExtreme and Wrappers timeout-minutes: 40 @@ -70,6 +79,7 @@ jobs: retention-days: 1 test-wrappers: + if: ${{ !(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'renovation')) }} needs: build-packages runs-on: devextreme-shr2 name: Test ${{ matrix.framework }} diff --git a/.gitignore b/.gitignore index 7d866b16285d..1fbad3c95159 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ obj # dependencies node_modules +.pnpm-store # IDEs and editors /.idea diff --git a/apps/demos/Demos/Accordion/Overview/jQuery/index.html b/apps/demos/Demos/Accordion/Overview/jQuery/index.html index 24ca7d32c6ff..aed9b6f3e18d 100644 --- a/apps/demos/Demos/Accordion/Overview/jQuery/index.html +++ b/apps/demos/Demos/Accordion/Overview/jQuery/index.html @@ -6,7 +6,7 @@ - + diff --git a/apps/demos/package.json b/apps/demos/package.json index 631cd1f5f45b..1893379f90c7 100644 --- a/apps/demos/package.json +++ b/apps/demos/package.json @@ -102,6 +102,7 @@ "@eslint/compat": "1.4.1", "@eslint/eslintrc": "catalog:", "@eslint/js": "catalog:", + "@playwright/test": "1.60.0", "@rollup/plugin-babel": "6.1.0", "@rollup/plugin-node-resolve": "15.3.1", "@rollup/plugin-replace": "5.0.7", @@ -183,6 +184,13 @@ "test-testcafe": "ts-node utils/visual-tests/testcafe-runner.ts", "test-testcafe:csp": "cross-env CSP_REPORT=true ts-node utils/visual-tests/testcafe-runner.ts", "test-testcafe:accessibility": "cross-env STRATEGY=accessibility CONSTEL=jquery node utils/visual-tests/testcafe-runner.ts", + "test-playwright-common-screenshots": "playwright test --config=playwright.common-screenshots.config.ts", + "test-playwright-widgets-screenshots": "cross-env STRATEGY=widgets playwright test --config=playwright.common-screenshots.config.ts", + "test-playwright-common-accessibility": "cross-env STRATEGY=accessibility playwright test --config=playwright.common-screenshots.config.ts", + "test-playwright-common-csp": "cross-env STRATEGY=csp playwright test --config=playwright.common-screenshots.config.ts", + "test-playwright-common-screenshots:docker": "bash utils/visual-tests/playwright/docker/run-common-screenshots.sh", + "test-playwright-widgets-screenshots:docker": "cross-env STRATEGY=widgets bash utils/visual-tests/playwright/docker/run-common-screenshots.sh", + "test-playwright-common-accessibility:docker": "cross-env STRATEGY=accessibility CONSTEL=jquery bash utils/visual-tests/playwright/docker/run-common-screenshots.sh", "csp-server": "node utils/server/csp-server.js 8080", "csp-check": "node utils/server/csp-check.js", "fix-lint": "prettier --write . && eslint --fix . && stylelint **/*.{css,vue} --fix", diff --git a/apps/demos/playwright.common-screenshots.config.ts b/apps/demos/playwright.common-screenshots.config.ts new file mode 100644 index 000000000000..065f1b4d266e --- /dev/null +++ b/apps/demos/playwright.common-screenshots.config.ts @@ -0,0 +1,149 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { defineConfig } from '@playwright/test'; + +const DEFAULT_CHROMIUM_ARGS = [ + '--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', +]; + +const FONT_RENDERING_CHROMIUM_ARGS = [ + '--font-render-hinting=none', + '--disable-font-subpixel-positioning', +]; + +// Applied for Material theme only. +// --disable-lcd-text: disables LCD/ClearType subpixel colour antialiasing on +// coloured glyphs (Roboto headings, links). TestCafe screenshots use grayscale +// antialiasing; without this flag Playwright renders coloured fringes that +// differ from the etalons. +// --force-color-profile=srgb: locks Chrome to sRGB so coloured elements +// (sliders, icons, links) render at the same hue as TestCafe etalons, +// regardless of the CI machine's ICC display profile. +const MATERIAL_CHROMIUM_ARGS = [ + '--disable-lcd-text', + '--force-color-profile=srgb', +]; + +function isMaterialTheme(theme = process.env.THEME || ''): boolean { + return theme.includes('material'); +} + +function getDefaultChromiumArgs(theme = process.env.THEME || ''): string[] { + if (isMaterialTheme(theme)) { + return [...DEFAULT_CHROMIUM_ARGS, ...MATERIAL_CHROMIUM_ARGS]; + } + + return [ + ...DEFAULT_CHROMIUM_ARGS, + ...FONT_RENDERING_CHROMIUM_ARGS, + ]; +} + +function normalizeBrowserArgs(browserArgs: string[], theme = process.env.THEME || ''): string[] { + if (!isMaterialTheme(theme)) { + return browserArgs; + } + + const filtered = browserArgs.filter((arg) => !FONT_RENDERING_CHROMIUM_ARGS.includes(arg)); + const missing = MATERIAL_CHROMIUM_ARGS.filter((arg) => !filtered.includes(arg)); + return [...filtered, ...missing]; +} + +// CDPScreenshotNewSurface (Chrome ≥132) changes how CDP takes screenshots and +// produces different Roboto/Material text antialiasing vs. TestCafe etalons. +// When PLAYWRIGHT_LEGACY_SCREENSHOT=1 is set, disable it so captures match. +function applyLegacyScreenshot(args: string[]): string[] { + if (process.env.PLAYWRIGHT_LEGACY_SCREENSHOT !== '1') { + return args; + } + + if (!args.some((arg) => arg.startsWith('--disable-features='))) { + return [ + ...args, + '--disable-features=CDPScreenshotNewSurface', + ]; + } + + return args.map((arg) => (arg.startsWith('--disable-features=') + ? `${arg},CDPScreenshotNewSurface` + : arg)); +} + +function parseBrowserArgs(browserEnv = ''): string[] { + const browserArgs = browserEnv + .split(/\s+/) + .map((value) => value.replace(/^"|"$/g, '')) + .filter((value) => value.startsWith('--')); + + return applyLegacyScreenshot(normalizeBrowserArgs( + browserArgs.length ? browserArgs : getDefaultChromiumArgs(), + )); +} + +function parseHeadless(browserEnv = ''): boolean { + if (!browserEnv) { + return true; + } + + return browserEnv.includes('headless'); +} + +function isAccessibilityStrategy(strategy = process.env.STRATEGY || ''): boolean { + return strategy === 'accessibility'; +} + +function getViewport(strategy = process.env.STRATEGY || ''): { width: number; height: number } { + return isAccessibilityStrategy(strategy) + ? { width: 1200, height: 800 } + : { width: 1000, height: 800 }; +} + +function getTestMatch(strategy = process.env.STRATEGY || ''): string { + return strategy === 'widgets' + ? 'manual-widgets.spec.ts' + : 'common-screenshots.spec.ts'; +} + +const strategy = process.env.STRATEGY || 'screenshots'; +const workers = (process.env.CONCURRENCY && Number(process.env.CONCURRENCY)) || 1; +const browserEnv = process.env.BROWSERS || ''; + +export default defineConfig({ + testDir: './utils/visual-tests/playwright', + testMatch: getTestMatch(strategy), + timeout: 3 * 60 * 1000, + fullyParallel: true, + workers, + retries: process.env.TCQUARANTINE ? 0 : 0, // 2 : 0 + forbidOnly: Boolean(process.env.CI || process.env.CI_ENV), + outputDir: `./testing/artifacts/playwright-common-${strategy}`, + reporter: process.env.CI || process.env.CI_ENV + ? [ + ['list'], + ['html', { open: 'never', outputFolder: './testing/artifacts/playwright-report' }], + ] + : 'list', + use: { + browserName: 'chromium', + channel: process.env.PLAYWRIGHT_CHROMIUM_CHANNEL || 'chrome', + headless: parseHeadless(browserEnv), + viewport: getViewport(strategy), + deviceScaleFactor: 1, + actionTimeout: 10000, + navigationTimeout: 10000, + launchOptions: { + args: parseBrowserArgs(browserEnv), + }, + }, +}); diff --git a/apps/demos/project.json b/apps/demos/project.json index 92602e2a4ffe..cafebee1d488 100644 --- a/apps/demos/project.json +++ b/apps/demos/project.json @@ -48,6 +48,111 @@ "{projectRoot}/testing/artifacts" ] }, + "test-playwright-common-screenshots": { + "executor": "nx:run-script", + "options": { + "script": "test-playwright-common-screenshots" + }, + "dependsOn": [ + // NOTE: uncomment me when the NX cache is fixed to work in GHA + // "devextreme:build" + ], + "inputs": [ + { "env": "STRATEGY" }, + { "env": "CHANGEDFILEINFOSPATH" }, + { "env": "BROWSERS" }, + { "env": "DEBUG" }, + { "env": "TCQUARANTINE" }, + { "env": "CONSTEL" }, + { "env": "THEME" }, + { "env": "DISABLE_DEMO_TEST_SETTINGS" }, + { "env": "CI_ENV" }, + "default", + "test" + ], + "outputs": [ + "{projectRoot}/testing/artifacts" + ] + }, + "test-playwright-widgets-screenshots": { + "executor": "nx:run-script", + "options": { + "script": "test-playwright-widgets-screenshots" + }, + "dependsOn": [ + // NOTE: uncomment me when the NX cache is fixed to work in GHA + // "devextreme:build" + ], + "inputs": [ + { "env": "STRATEGY" }, + { "env": "CHANGEDFILEINFOSPATH" }, + { "env": "BROWSERS" }, + { "env": "DEBUG" }, + { "env": "TCQUARANTINE" }, + { "env": "CONSTEL" }, + { "env": "THEME" }, + { "env": "DISABLE_DEMO_TEST_SETTINGS" }, + { "env": "CI_ENV" }, + "default", + "test" + ], + "outputs": [ + "{projectRoot}/testing/artifacts" + ] + }, + "test-playwright-common-accessibility": { + "executor": "nx:run-script", + "options": { + "script": "test-playwright-common-accessibility" + }, + "dependsOn": [ + // NOTE: uncomment me when the NX cache is fixed to work in GHA + // "devextreme:build" + ], + "inputs": [ + { "env": "STRATEGY" }, + { "env": "CHANGEDFILEINFOSPATH" }, + { "env": "BROWSERS" }, + { "env": "DEBUG" }, + { "env": "TCQUARANTINE" }, + { "env": "CONSTEL" }, + { "env": "THEME" }, + { "env": "DISABLE_DEMO_TEST_SETTINGS" }, + { "env": "CI_ENV" }, + "default", + "test" + ], + "outputs": [ + "{projectRoot}/testing/artifacts" + ] + }, + "test-playwright-common-csp": { + "executor": "nx:run-script", + "options": { + "script": "test-playwright-common-csp" + }, + "dependsOn": [ + // NOTE: uncomment me when the NX cache is fixed to work in GHA + // "devextreme:build" + ], + "inputs": [ + { "env": "STRATEGY" }, + { "env": "CHANGEDFILEINFOSPATH" }, + { "env": "BROWSERS" }, + { "env": "DEBUG" }, + { "env": "TCQUARANTINE" }, + { "env": "CONSTEL" }, + { "env": "THEME" }, + { "env": "DISABLE_DEMO_TEST_SETTINGS" }, + { "env": "CI_ENV" }, + "default", + "test" + ], + "outputs": [ + "{projectRoot}/csp-reports", + "{projectRoot}/testing/artifacts" + ] + }, "prepare-bundles": { "executor": "nx:run-script", "options": { diff --git a/apps/demos/tsconfig.eslint.json b/apps/demos/tsconfig.eslint.json index 2ca7de947005..7267c1cec9cf 100644 --- a/apps/demos/tsconfig.eslint.json +++ b/apps/demos/tsconfig.eslint.json @@ -13,6 +13,7 @@ "lib": ["es2022", "DOM", "DOM.Iterable"] }, "include": [ + "./playwright.*.ts", "./Demos/**/React/**/*.ts", "./Demos/**/React/**/*.tsx", "./Demos/**/Vue/**/*.ts", diff --git a/apps/demos/tsconfig.json b/apps/demos/tsconfig.json index 115ef31c7ffe..026a75518aba 100644 --- a/apps/demos/tsconfig.json +++ b/apps/demos/tsconfig.json @@ -10,6 +10,7 @@ "module": "CommonJS" }, "include": [ + "playwright.*.ts", "utils/**/*.ts", "testing/**/*.ts" ], diff --git a/apps/demos/utils/visual-tests/matrix-test-helper-core.ts b/apps/demos/utils/visual-tests/matrix-test-helper-core.ts new file mode 100644 index 000000000000..5250e5dc31f1 --- /dev/null +++ b/apps/demos/utils/visual-tests/matrix-test-helper-core.ts @@ -0,0 +1,248 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +export const FRAMEWORKS = { + jquery: 'jQuery', + react: 'React', + vue: 'Vue', + angular: 'Angular', +} as const; + +export const THEME = { + generic: 'generic.light', + fluent: 'fluent.blue.light', + material: 'material.blue.light', +} as const; + +export type Framework = typeof FRAMEWORKS[keyof typeof FRAMEWORKS]; + +interface ExplicitTests { + masks: { + product: RegExp; + demo: RegExp; + framework: RegExp; + }[]; +} + +export interface MatrixTestSettings { + targetFramework?: string; + total?: number; + current?: number; + explicitTests?: ExplicitTests; + ignoreChangesPathPatterns: RegExp[]; + changedFilePatterns: RegExp[]; +} + +export function createMatrixTestSettings( + options: { includeManualTests?: boolean } = {}, +): MatrixTestSettings { + const changedFilePatterns = [ + /Demos\/(?\w+)\/(?\w+)\/(?[Aa]ngular|[Jj][Qq]uery|[Rr]eact|[Vv]ue)\/.*/, + /Demos\/(?\w+)\/(?\w+)\/(?.*)/, + /testing\/etalons\/(?\w+)-(?\w+)(?.*).png/i, + ]; + + if (options.includeManualTests) { + changedFilePatterns.push(/testing\/widgets\/(?\w+)\/.*/i); + } + + return { + ignoreChangesPathPatterns: [ + /mvcdemos.*/i, + /netcoredemos.*/i, + /menumeta.json/i, + /.*.md/i, + ], + changedFilePatterns, + }; +} + +export function globalReadFrom( + basePath: string, + relativePath: string, + mapCallback?: (data: string) => T, +): T | string | null { + const absolute = join(basePath, relativePath); + if (existsSync(absolute)) { + const result = readFileSync(absolute, 'utf8'); + return (mapCallback && result && mapCallback(result)) || result; + } + return null; +} + +export function injectStyle(style: string): string { + return ` + var style = document.createElement('style'); + style.innerHTML = \`${style}\`; + document.getElementsByTagName('head')[0].appendChild(style); + `; +} + +export function changeTheme(dirName: string, demoPath: string, theme?: string): void { + if (!theme || theme === THEME.generic) { + return; + } + + const updatedContent = globalReadFrom(dirName, demoPath, (data) => { + let result = data.replace(/data-theme="[^"]+"/g, `data-theme="${theme}"`); + + result = result.replace(/dx\.[^"]+\.css/g, `dx.${theme}.css`); + + return result; + }); + + const indexFilePath = join(dirName, demoPath); + + if (existsSync(indexFilePath) && typeof updatedContent === 'string') { + writeFileSync(indexFilePath, updatedContent, 'utf8'); + } +} + +function patternGroupFromValues(product?: string, demo?: string, framework?: string) { + const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&'); + const wrap = (value?: string): RegExp => RegExp(value ? escapeRegExp(value) : '.*', 'i'); + + return { + product: wrap(product), + demo: wrap(demo), + framework: wrap(framework), + }; +} + +function getChangedFiles(): { filename: string }[] | undefined { + const changedFilesPath = process.env.CHANGEDFILEINFOSPATH; + + return changedFilesPath + && existsSync(changedFilesPath) + && JSON.parse(readFileSync(changedFilesPath, 'utf8')); +} + +function getExplicitTestsFromChangedFiles(settings: MatrixTestSettings): ExplicitTests | undefined { + const changedFiles = getChangedFiles(); + if (!changedFiles) { + return undefined; + } + if (!Array.isArray(changedFiles)) { + console.log('Running all tests. Changed files are not iterable: ', JSON.stringify(changedFiles)); + return undefined; + } + + const result: ExplicitTests = { masks: [] }; + + for (const changedFile of changedFiles) { + const fileName = changedFile.filename; + + if (!settings.ignoreChangesPathPatterns.some((pattern) => pattern.test(fileName))) { + const parseResult = settings.changedFilePatterns + .map((pattern) => pattern.exec(fileName)) + .find((result) => result); + + if (!parseResult) { + console.log('Unable to parse changed file, running all tests: ', fileName); + return undefined; + } + + const groups = parseResult.groups || {}; + result.masks.push(patternGroupFromValues( + groups.product, + groups.demo, + undefined, + )); + } + } + + return result; +} + +export function updateMatrixTestSettings(settings: MatrixTestSettings): void { + if (process.env.CONSTEL) { + const match = process.env.CONSTEL.match(/(?\w+)(?\((?\d+)\/(?\d+)\))?/); + if (match?.groups) { + settings.targetFramework = match.groups.name; + if (match.groups.parallel) { + settings.total = Number(match.groups.total); + settings.current = Number(match.groups.current); + } + } + } + + settings.explicitTests = getExplicitTestsFromChangedFiles(settings); +} + +export function shouldRunFramework(settings: MatrixTestSettings, currentFramework: Framework): boolean { + return !settings.targetFramework + || currentFramework.toLowerCase() === settings.targetFramework.toLowerCase(); +} + +export function shouldRunTestAtIndex(settings: MatrixTestSettings, testIndex: number): boolean { + if (!settings.total || !settings.current) { + return true; + } + + const part = testIndex % settings.total; + const currentPart = settings.current - 1; + + return part === currentPart; +} + +export function shouldRunTestExplicitly(settings: MatrixTestSettings, demoUrl: string): boolean { + if (!settings.explicitTests) { + return true; + } + + const parts = demoUrl.split('/').filter((x) => x?.length); + const framework = parts[parts.length - 1]; + const product = parts[parts.length - 3]; + const demo = parts[parts.length - 2]; + + return settings.explicitTests.masks.some((mask) => mask.framework.test(framework) + && mask.demo.test(demo) + && mask.product.test(product)); +} + +export function shouldSkipDemo( + framework: Framework, + component: string, + demoName: string, + skippedTests: Record>, + theme = process.env.THEME || THEME.generic, +): boolean { + const frameworkTests = skippedTests[framework]; + if (!frameworkTests) { + return false; + } + + const componentTests = frameworkTests[component]; + if (!componentTests) { + return false; + } + + for (const test of componentTests) { + if (typeof test === 'string' && test === demoName) { + return true; + } + if (typeof test !== 'string' + && test.demo === demoName + && test.themes.includes(theme)) { + return true; + } + } + + return false; +} + +export function getDemoParts(demoPath: string): { + widgetName: string; + demoName: string; + testName: string; +} { + const testParts = demoPath.split(/[/\\]/); + const widgetName = testParts[1]; + const demoName = testParts[2]; + + return { + widgetName, + demoName, + testName: `${widgetName}-${demoName}`, + }; +} diff --git a/apps/demos/utils/visual-tests/playwright/accessibility.ts b/apps/demos/utils/visual-tests/playwright/accessibility.ts new file mode 100644 index 000000000000..d2a3ab2769c8 --- /dev/null +++ b/apps/demos/utils/visual-tests/playwright/accessibility.ts @@ -0,0 +1,176 @@ +import type { Page } from '@playwright/test'; +import type { AxeResults, Result, RunOptions } from 'axe-core'; + +import { createMdReport } from '../../axe-reporter/reporter'; + +const ACCESSIBILITY_TARGET_SELECTOR = '.demo-container'; + +interface AccessibilityCheckResult { + isValid: boolean; + errorMessage: string; +} + +type Impact = 'minor' | 'moderate' | 'serious' | 'critical' | 'unknown'; + +function isMaterialOrFluent(theme = process.env.THEME || ''): boolean { + return theme.startsWith('material') || theme.startsWith('fluent'); +} + +function getIgnoredRules(testName: string): string[] { + const ignoredRules: string[] = []; + + if (isMaterialOrFluent() + && [ + 'Accordion-Overview', + 'TagBox-Overview', + 'TreeList-StatePersistence', + 'CardView-FieldTemplate', + 'VectorMap-DynamicViewport', + ].includes(testName) + ) { + ignoredRules.push('color-contrast'); + } + + const specificRules: Record = { + 'DataGrid-EditStateManagement': ['aria-required-parent'], + 'DataGrid-RemoteCRUDOperations': ['scrollable-region-focusable'], + + 'Diagram-Adaptability': ['aria-dialog-name', 'label'], + 'Diagram-AdvancedDataBinding': ['aria-dialog-name', 'label'], + 'Diagram-Containers': ['aria-dialog-name', 'label'], + 'Diagram-CustomShapesWithIcons': ['aria-dialog-name', 'label'], + 'Diagram-CustomShapesWithTemplates': ['label'], + 'Diagram-CustomShapesWithTemplatesWithEditing': ['aria-dialog-name', 'label'], + 'Diagram-CustomShapesWithTexts': ['aria-dialog-name', 'label'], + 'Diagram-ImagesInShapes': ['aria-dialog-name', 'label'], + 'Diagram-ItemSelection': ['label'], + 'Diagram-NodesAndEdgesArrays': ['aria-dialog-name', 'label'], + 'Diagram-NodesArrayHierarchicalStructure': ['aria-dialog-name', 'label'], + 'Diagram-NodesArrayPlainStructure': ['aria-dialog-name', 'label'], + 'Diagram-OperationRestrictions': ['aria-dialog-name', 'label'], + 'Diagram-Overview': ['aria-dialog-name', 'label'], + 'Diagram-ReadOnly': ['label'], + 'Diagram-SimpleView': ['label'], + 'Diagram-UICustomization': ['aria-dialog-name', 'label'], + 'Diagram-WebAPIService': ['aria-dialog-name', 'label'], + + 'FileManager-BindingToEF': ['aria-command-name', 'empty-table-header', 'label'], + 'FileManager-BindingToFileSystem': ['aria-command-name', 'empty-table-header', 'label'], + 'FileManager-BindingToHierarchicalStructure': ['aria-command-name', 'empty-table-header', 'label'], + 'FileManager-CustomThumbnails': ['aria-allowed-attr', 'aria-command-name', 'image-alt', 'label'], + 'FileManager-Overview': ['aria-command-name', 'empty-table-header', 'label'], + 'FileManager-UICustomization': ['aria-command-name', 'empty-table-header', 'label'], + + 'Gantt-Appearance': ['aria-toggle-field-name'], + 'Gantt-ExportToPDF': ['aria-toggle-field-name'], + 'Gantt-Overview': ['aria-required-parent', 'aria-valid-attr-value'], + 'Gantt-StripLines': ['aria-required-parent', 'aria-valid-attr-value'], + 'Gantt-Validation': ['aria-required-parent', 'aria-valid-attr-value'], + + 'Localization-UsingGlobalize': ['label'], + }; + + return [ + ...ignoredRules, + ...(specificRules[testName] || []), + ]; +} + +function getAxeOptions(testName: string): RunOptions { + const rules: Record = {}; + + getIgnoredRules(testName).forEach((ruleName) => { + rules[ruleName] = { enabled: false }; + }); + + return { rules }; +} + +function getImpact(impact: Result['impact']): Impact { + return impact || 'unknown'; +} + +function getImpactSummary(violations: Result[]): string { + const counts: Record = { + minor: 0, + moderate: 0, + serious: 0, + critical: 0, + unknown: 0, + }; + + violations.forEach((violation) => { + counts[getImpact(violation.impact)] += 1; + }); + + return `${violations.length} accessibility violations found ` + + `(${counts.critical} critical, ${counts.serious} serious, ` + + `${counts.moderate} moderate, ${counts.minor} minor, ${counts.unknown} unknown)`; +} + +function formatFailureSummary(summary?: string): string { + if (!summary) { + return ''; + } + + return `\n ${summary.replace(/\n/g, '\n ')}`; +} + +function formatViolation(violation: Result): string { + const nodes = violation.nodes + .map((node) => ` - ${node.target.join(', ')}${formatFailureSummary(node.failureSummary)}`) + .join('\n'); + + return [ + `${violation.id} (${getImpact(violation.impact)}): ${violation.help}`, + ` ${violation.helpUrl}`, + nodes, + ].filter(Boolean).join('\n'); +} + +function createAccessibilityReport(violations: Result[]): string { + if (!violations.length) { + return ''; + } + + return [ + getImpactSummary(violations), + ...violations.map(formatViolation), + ].join('\n\n'); +} + +export async function checkDemoAccessibility( + page: Page, + testName: string, +): Promise { + const options = getAxeOptions(testName); + const results = await page.evaluate(({ selector, runOptions }) => { + const axe = (window as typeof window & { + axe?: { + run: (context: Element, options: RunOptions) => Promise; + }; + }).axe; + const context = document.querySelector(selector); + + if (!axe) { + throw new Error('axe-core is not loaded'); + } + if (!context) { + throw new Error(`Accessibility target "${selector}" was not found`); + } + + return axe.run(context, runOptions); + }, { + selector: ACCESSIBILITY_TARGET_SELECTOR, + runOptions: options, + }); + + if (results.violations.length > 0) { + createMdReport({ testName, results }); + } + + return { + isValid: results.violations.length === 0, + errorMessage: createAccessibilityReport(results.violations), + }; +} diff --git a/apps/demos/utils/visual-tests/playwright/common-screenshots-utils.ts b/apps/demos/utils/visual-tests/playwright/common-screenshots-utils.ts new file mode 100644 index 000000000000..1a33700e0c79 --- /dev/null +++ b/apps/demos/utils/visual-tests/playwright/common-screenshots-utils.ts @@ -0,0 +1,256 @@ +import { existsSync, mkdirSync } from 'fs'; +import { createRequire } from 'module'; +import { join, resolve } from 'path'; +import { glob } from 'glob'; +import type { Page } from '@playwright/test'; + +import { + changeTheme, + createMatrixTestSettings, + FRAMEWORKS, + getDemoParts as getMatrixDemoParts, + globalReadFrom, + injectStyle, + shouldRunFramework as shouldRunMatrixFramework, + shouldRunTestAtIndex as shouldRunMatrixTestAtIndex, + shouldRunTestExplicitly as shouldRunMatrixTestExplicitly, + shouldSkipDemo as shouldSkipMatrixDemo, + THEME, + updateMatrixTestSettings, + type Framework, +} from '../matrix-test-helper-core'; +import { gitHubIgnored } from '../github-ignored-list'; +import { skippedTests } from '../../../testing/skipped-tests'; + +const nodeRequire = createRequire(__filename); + +export const DEMOS_ROOT = resolve(__dirname, '../../..'); +export { + changeTheme, + FRAMEWORKS, + globalReadFrom, + injectStyle, + THEME, +}; + +const settings = createMatrixTestSettings(); + +export function updateConfig(): void { + updateMatrixTestSettings(settings); +} + +export function shouldRunFramework(currentFramework: Framework): boolean { + return shouldRunMatrixFramework(settings, currentFramework); +} + +export function shouldRunTestAtIndex(testIndex: number): boolean { + return shouldRunMatrixTestAtIndex(settings, testIndex); +} + +export function shouldRunTestExplicitly(demoUrl: string): boolean { + return shouldRunMatrixTestExplicitly(settings, demoUrl); +} + +export function shouldSkipDemo(framework: Framework, component: string, demoName: string): boolean { + return shouldSkipMatrixDemo(framework, component, demoName, skippedTests); +} + +export function isAccessibilityStrategy(): boolean { + return process.env.STRATEGY === 'accessibility'; +} + +export function isCspStrategy(): boolean { + return process.env.STRATEGY === 'csp'; +} + +export function getDemoPaths(platform: Framework): string[] { + return glob.sync('Demos/*/*', { cwd: DEMOS_ROOT }) + .map((path) => join(path, platform)); +} + +interface ClientScript { + module?: string; + path?: string; + content?: string; +} + +function getClientScripts(): (ClientScript | string)[] { + const testStyles = globalReadFrom( + DEMOS_ROOT, + 'utils/visual-tests/inject/test-styles.css', + (x) => x, + ) || ''; + + const scripts: (ClientScript | string)[] = [ + { module: 'mockdate' }, + ]; + + if (isAccessibilityStrategy()) { + scripts.push({ module: 'axe-core/axe.min.js' }); + } + + if (isCspStrategy()) { + scripts.push(join(DEMOS_ROOT, 'utils/visual-tests/inject/csp-listener.js')); + } + + scripts.push( + join(DEMOS_ROOT, 'utils/visual-tests/inject/test-utils.js'), + { + content: injectStyle(testStyles), + }, + { + content: injectStyle(` + html { + overflow: clip; + } + + html::-webkit-scrollbar, + body::-webkit-scrollbar { + display: none; + } + `), + }, + { + content: ` + window.addEventListener('error', function (e) { + console.error(e.message); + });`, + }, + ); + + return scripts; +} + +export async function addClientScripts( + page: Page, + scripts: (ClientScript | string)[], +): Promise { + for (const script of scripts) { + if (typeof script === 'string') { + await page.addInitScript({ path: script }); + } else if (script.module) { + await page.addInitScript({ path: nodeRequire.resolve(script.module, { paths: [DEMOS_ROOT] }) }); + } else if (script.path) { + await page.addInitScript({ path: script.path }); + } else if (script.content) { + await page.addInitScript(script.content); + } + } +} + +export async function addCommonClientScripts( + page: Page, + demoClientScripts: ClientScript[], +): Promise { + await addClientScripts(page, [ + ...getClientScripts(), + ...demoClientScripts, + ]); +} + +export async function execCode(page: Page, code: string): Promise { + await page.evaluate(async (source) => { + // eslint-disable-next-line no-eval + const result = eval(source); + if (result && typeof result.then === 'function') { + await Promise.race([ + result, + new Promise((resolvePromise) => { setTimeout(resolvePromise, 60000); }), + ]); + } + }, code); +} + +export async function execTestCafeCode(page: Page, code: string): Promise { + // eslint-disable-next-line no-eval + const testCafeFunction = eval(code); + await testCafeFunction({ + click: (selector: string) => page.locator(selector).click(), + }); +} + +export async function waitForAngularLoading(page: Page): Promise { + await page.waitForFunction(() => { + const demoApp = document.querySelector('demo-app'); + return demoApp && (demoApp as HTMLElement).innerText !== 'Loading...'; + }, undefined, { timeout: 120000 }).catch(() => {}); + await page.waitForTimeout(500); +} + +export async function resetPageState(page: Page): Promise { + await page.evaluate(() => { + if (document.activeElement && document.activeElement !== document.body) { + (document.activeElement as HTMLElement).blur(); + } + window.getSelection()?.removeAllRanges(); + }); + + await page.locator('html').hover({ position: { x: 1, y: 1 } }); +} + +export async function waitForStableRendering(page: Page): Promise { + await page.evaluate(async () => { + const fonts = (document as Document & { fonts?: { ready: Promise } }).fonts; + + if (fonts?.ready) { + await Promise.race([ + fonts.ready, + new Promise((resolve) => { setTimeout(resolve, 5000); }), + ]); + } + + await new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + }); + }); +} + +export function ensureDir(path: string): void { + if (!existsSync(path)) { + mkdirSync(path, { recursive: true }); + } +} + +function getDemosBaseUrl(): string { + return (process.env.PLAYWRIGHT_DEMOS_BASE_URL || 'http://127.0.0.1:8080').replace(/\/$/, ''); +} + +export function getPageUrl(widgetName: string, demoName: string, approach: Framework): string | null { + const isGitHubDemos = process.env.ISGITHUBDEMOS; + const theme = (process.env.THEME || THEME.generic).replace('generic.', ''); + const baseUrl = getDemosBaseUrl(); + + if (isGitHubDemos) { + if (widgetName !== 'DataGrid' || gitHubIgnored.includes(demoName)) { + return null; + } + + return `${baseUrl}/Demos/${widgetName}/${demoName}/${approach}/?theme=dx.${theme}`; + } + + return `${baseUrl}/apps/demos/Demos/${widgetName}/${demoName}/${approach}/`; +} + +export function prepareDemoPage(widgetName: string, demoName: string, approach: Framework): void { + if (!process.env.ISGITHUBDEMOS) { + changeTheme(DEMOS_ROOT, `Demos/${widgetName}/${demoName}/${approach}/index.html`, process.env.THEME); + } +} + +export function getDemoParts(demoPath: string): { + widgetName: string; + demoName: string; + testName: string; +} { + return getMatrixDemoParts(demoPath); +} + +export function readDemoFile( + demoPath: string, + relativePath: string, + mapCallback?: (data: string) => T, +): T | string | null { + return globalReadFrom(join(DEMOS_ROOT, demoPath), relativePath, mapCallback); +} diff --git a/apps/demos/utils/visual-tests/playwright/common-screenshots.spec.ts b/apps/demos/utils/visual-tests/playwright/common-screenshots.spec.ts new file mode 100644 index 000000000000..8fe9a9b3e44f --- /dev/null +++ b/apps/demos/utils/visual-tests/playwright/common-screenshots.spec.ts @@ -0,0 +1,154 @@ +import { expect, test } from '@playwright/test'; + +import { + addCommonClientScripts, + DEMOS_ROOT, + execCode, + execTestCafeCode, + FRAMEWORKS, + getDemoParts, + getDemoPaths, + getPageUrl, + isAccessibilityStrategy, + isCspStrategy, + prepareDemoPage, + readDemoFile, + resetPageState, + shouldRunFramework, + shouldRunTestAtIndex, + shouldRunTestExplicitly, + shouldSkipDemo, + updateConfig, + waitForAngularLoading, + waitForStableRendering, +} from './common-screenshots-utils'; +import { checkDemoAccessibility } from './accessibility'; +import { collectCspViolations, writeCspReport } from './csp'; +import { compareDemoScreenshot } from './screenshot-comparer'; + +updateConfig(); + +test.describe.configure({ mode: 'parallel' }); + +const isAccessibility = isAccessibilityStrategy(); +const isCsp = isCspStrategy(); + +Object.values(FRAMEWORKS).forEach((approach) => { + if (!shouldRunFramework(approach)) { + return; + } + + test.describe(approach, () => { + getDemoPaths(approach).forEach((demoPath, index) => { + if (!shouldRunTestAtIndex(index + 1)) { + return; + } + + const { widgetName, demoName, testName } = getDemoParts(demoPath); + const pageURL = getPageUrl(widgetName, demoName, approach); + + if (!pageURL || !shouldRunTestExplicitly(pageURL)) { + return; + } + + const demoExists = readDemoFile(demoPath, 'index.html'); + if (!demoExists) { + return; + } + + const visualTestSettings = readDemoFile(demoPath, '../visualtestrc.json', (x) => JSON.parse(x)); + const clientScriptSource = readDemoFile(demoPath, '../client-script.js', (x) => [{ content: x }]) || []; + const testCodeSource = readDemoFile(demoPath, '../test-code.js', (x) => x); + const testCafeCodeSource = readDemoFile(demoPath, '../testcafe-test-code.js', (x) => x); + const visualTestStyles = readDemoFile( + demoPath, + '../test-styles.css', + (x) => ` + var style = document.createElement('style'); + style.innerHTML = \`${x}\`; + document.getElementsByTagName('head')[0].appendChild(style); + `, + ); + + let comparisonOptions: Parameters[2]; + if (process.env.DISABLE_DEMO_TEST_SETTINGS !== 'all') { + const approachLowerCase = approach.toLowerCase(); + const mergedTestSettings = (visualTestSettings && { + ...visualTestSettings, + ...visualTestSettings[approachLowerCase], + }) || {}; + + if (process.env.CI_ENV && process.env.DISABLE_DEMO_TEST_SETTINGS !== 'ignore') { + if (mergedTestSettings.ignore) { + return; + } + } + if (process.env.DISABLE_DEMO_TEST_SETTINGS !== 'comparison-options') { + comparisonOptions = mergedTestSettings['comparison-options']; + } + } + + if (!isAccessibility && shouldSkipDemo(approach, widgetName, demoName)) { + return; + } + + test(testName, async ({ page }) => { + await addCommonClientScripts(page, clientScriptSource as { content: string }[]); + prepareDemoPage(widgetName, demoName, approach); + await page.goto(pageURL); + await resetPageState(page); + + if (visualTestStyles) { + await execCode(page, visualTestStyles); + } + + if (approach === 'Angular') { + await waitForAngularLoading(page); + } + + // For CSP checking, skip test-code execution: those scripts manipulate + // widget state for screenshots and may throw if CSP blocked demo scripts. + // CSP violations are collected from page load, not from test interactions. + if (!isCsp) { + if (testCodeSource) { + await execCode(page, testCodeSource); + } + + if (testCafeCodeSource) { + await execTestCafeCode(page, testCafeCodeSource); + } + } + + await waitForStableRendering(page); + + if (isAccessibility) { + const accessibilityResult = await checkDemoAccessibility(page, testName); + + expect(accessibilityResult.isValid, accessibilityResult.errorMessage).toBe(true); + return; + } + + if (isCsp) { + const violations = await collectCspViolations(page); + writeCspReport(testName, approach, violations); + return; + } + + const compareResult = await compareDemoScreenshot( + page, + `${testName}.png`, + comparisonOptions, + ); + + expect(compareResult.isValid, compareResult.errorMessage).toBe(true); + }); + }); + }); +}); + +test.afterAll(() => { + // eslint-disable-next-line no-nested-ternary + const strategyName = isAccessibility ? 'accessibility' : isCsp ? 'csp' : 'screenshots'; + + console.log(`Playwright common ${strategyName} root: ${DEMOS_ROOT}`); +}); diff --git a/apps/demos/utils/visual-tests/playwright/csp.ts b/apps/demos/utils/visual-tests/playwright/csp.ts new file mode 100644 index 000000000000..88ff5d6d19a2 --- /dev/null +++ b/apps/demos/utils/visual-tests/playwright/csp.ts @@ -0,0 +1,42 @@ +import { appendFileSync, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import type { Page } from '@playwright/test'; + +import { DEMOS_ROOT } from './common-screenshots-utils'; + +const cspReportDir = join(DEMOS_ROOT, 'csp-reports'); +const cspReportFile = join(cspReportDir, 'csp-violations.jsonl'); + +interface CspViolation { + blockedURI: string; + violatedDirective: string; + effectiveDirective: string; + originalPolicy: string; + sourceFile: string; + lineNumber: number; + columnNumber: number; + documentURI: string; + disposition: string; + timestamp: string; +} + +export function collectCspViolations(page: Page): Promise { + return page.evaluate( + () => ((window as typeof window & { __cspViolations?: CspViolation[] }).__cspViolations || []), + ); +} + +export function writeCspReport(testName: string, framework: string, violations: CspViolation[]): void { + if (!violations.length) { + return; + } + + if (!existsSync(cspReportDir)) { + mkdirSync(cspReportDir, { recursive: true }); + } + + for (const v of violations) { + const entry = { test: testName, framework, ...v }; + appendFileSync(cspReportFile, `${JSON.stringify(entry)}\n`); + } +} diff --git a/apps/demos/utils/visual-tests/playwright/docker/Dockerfile b/apps/demos/utils/visual-tests/playwright/docker/Dockerfile new file mode 100644 index 000000000000..8a5963539127 --- /dev/null +++ b/apps/demos/utils/visual-tests/playwright/docker/Dockerfile @@ -0,0 +1,44 @@ +ARG BASE_IMAGE=devextreme-shr2:ubuntu-20.04 + +FROM ${BASE_IMAGE} + +ARG CHROME_VERSION=145.0.7632.67 + +USER root + +ENV DEBIAN_FRONTEND=noninteractive +ENV PATH="/usr/local/bin:${PATH}" + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + fontconfig \ + git \ + gnupg \ + p7zip-full \ + python3 \ + unzip \ + xz-utils \ + && rm -rf /var/lib/apt/lists/* + +RUN if ! command -v mise > /dev/null 2>&1; then \ + curl -fsSL https://mise.run | sh; \ + install -m 0755 /root/.local/bin/mise /usr/local/bin/mise; \ + fi + +RUN set -eux; \ + current_chrome_version="$(google-chrome-stable --product-version 2>/dev/null || true)"; \ + if [ "${current_chrome_version}" != "${CHROME_VERSION}" ]; then \ + apt-get update; \ + apt-get install -y --no-install-recommends libu2f-udev; \ + curl -fsSL \ + "https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}-1_amd64.deb" \ + -o /tmp/chrome.deb; \ + apt-get install -y /tmp/chrome.deb; \ + rm -f /tmp/chrome.deb; \ + rm -rf /var/lib/apt/lists/*; \ + fi; \ + google-chrome-stable --version + +WORKDIR /work/DevExtreme diff --git a/apps/demos/utils/visual-tests/playwright/docker/run-common-screenshots.sh b/apps/demos/utils/visual-tests/playwright/docker/run-common-screenshots.sh new file mode 100755 index 000000000000..f80e85252548 --- /dev/null +++ b/apps/demos/utils/visual-tests/playwright/docker/run-common-screenshots.sh @@ -0,0 +1,438 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../../../.." && pwd -P)" +CONTAINER_REPO_ROOT="/work/DevExtreme" +CONTAINER_SCRIPT="${CONTAINER_REPO_ROOT}/apps/demos/utils/visual-tests/playwright/docker/run-common-screenshots.sh" + +log() { + printf '[playwright-docker] %s\n' "$*" +} + +is_truthy() { + case "${1:-}" in + 1|true|TRUE|yes|YES|on|ON) return 0 ;; + *) return 1 ;; + esac +} + +docker_platform_args() { + if [ -n "${PLAYWRIGHT_DOCKER_PLATFORM:-}" ]; then + printf '%s\n' --platform "${PLAYWRIGHT_DOCKER_PLATFORM}" + return + fi + + case "$(uname -m)" in + arm64|aarch64) printf '%s\n' --platform linux/amd64 ;; + *) ;; + esac +} + +ensure_base_image() { + local base_image="$1" + local upstream_context="${DEVEXTREME_SHR2_DOCKER_CONTEXT:-https://github.com/DevExpress/devextreme-shr2.git#main:docker-image-ubuntu-20.04}" + local platform_arg + local -a platform_args=() + + while IFS= read -r platform_arg; do + platform_args+=("${platform_arg}") + done < <(docker_platform_args) + + if docker image inspect "${base_image}" > /dev/null 2>&1; then + return + fi + + log "Base image '${base_image}' was not found locally." + log "Building it from '${upstream_context}'." + log "If that repository is private, build/pull '${base_image}' yourself or set DEVEXTREME_SHR2_IMAGE." + + docker build "${platform_args[@]}" -t "${base_image}" "${upstream_context}" +} + +build_runner_image() { + local base_image="${DEVEXTREME_SHR2_IMAGE:-devextreme-shr2:ubuntu-20.04}" + local runner_image="${PLAYWRIGHT_DOCKER_IMAGE:-devextreme-playwright-common-screenshots:ubuntu-20.04}" + local chrome_version="${PLAYWRIGHT_CHROME_VERSION:-145.0.7632.67}" + local platform_arg + local -a platform_args=() + + while IFS= read -r platform_arg; do + platform_args+=("${platform_arg}") + done < <(docker_platform_args) + + ensure_base_image "${base_image}" + + if docker image inspect "${runner_image}" > /dev/null 2>&1 \ + && ! is_truthy "${PLAYWRIGHT_DOCKER_REBUILD_IMAGE:-false}"; then + log "Using existing runner image '${runner_image}'. Set PLAYWRIGHT_DOCKER_REBUILD_IMAGE=true to rebuild it." + return + fi + + log "Building runner image '${runner_image}' from '${base_image}'." + docker build "${platform_args[@]}" \ + --build-arg "BASE_IMAGE=${base_image}" \ + --build-arg "CHROME_VERSION=${chrome_version}" \ + -t "${runner_image}" \ + -f "${SCRIPT_DIR}/Dockerfile" \ + "${SCRIPT_DIR}" +} + +get_node_modules_targets() { + printf '%s\n' node_modules + + find apps e2e packages \ + -mindepth 2 \ + -maxdepth 2 \ + -name package.json \ + -not -path 'packages/sbom/package.json' \ + -print \ + | sed 's#/package.json$#/node_modules#' +} + +get_volume_prefix() { + local repo_hash + + repo_hash="$(printf '%s' "${REPO_ROOT}" | cksum | awk '{ print $1 }')" + printf '%s\n' "${PLAYWRIGHT_DOCKER_VOLUME_PREFIX:-devextreme-playwright-${repo_hash}}" +} + +# Named volumes start root-owned, so a `--user $uid:$gid` container cannot +# write to them. Pre-chown each volume to the host UID/GID via a throwaway +# root container so subsequent pnpm/nx writes succeed. +prepare_volume_ownership() { + local uid="$1" + local gid="$2" + shift 2 + local -a chown_mounts=("$@") + local platform_arg + local -a platform_args=() + local -a mount_args=() + local mount_arg + local mount_path + local -a chown_paths=() + + if [ "${#chown_mounts[@]}" -eq 0 ]; then + return + fi + + while IFS= read -r platform_arg; do + platform_args+=("${platform_arg}") + done < <(docker_platform_args) + + # chown_mounts is a flat list alternating "-v" "name:path". Extract paths. + local idx + for ((idx = 1; idx < ${#chown_mounts[@]}; idx += 2)); do + mount_arg="${chown_mounts[$idx]}" + mount_path="${mount_arg#*:}" + chown_paths+=("${mount_path}") + mount_args+=(-v "${mount_arg}") + done + + log "Preparing named-volume ownership (uid=${uid} gid=${gid})." + docker run --rm "${platform_args[@]}" \ + "${mount_args[@]}" \ + busybox:latest \ + chown -R "${uid}:${gid}" "${chown_paths[@]}" > /dev/null +} + +run_host() { + local runner_image="${PLAYWRIGHT_DOCKER_IMAGE:-devextreme-playwright-common-screenshots:ubuntu-20.04}" + local uid + local gid + local cache_root + local home_dir + local volume_prefix + local node_modules_target + local platform_arg + local volume_name + local -a platform_args=() + local -a env_args=() + local -a tty_args=() + local -a node_modules_volume_args=() + + uid="$(id -u)" + gid="$(id -g)" + cache_root="${PLAYWRIGHT_DOCKER_CACHE_ROOT:-${HOME}/.cache/devextreme-playwright-docker}" + home_dir="${cache_root}/home" + volume_prefix="$(get_volume_prefix)" + + mkdir -p "${home_dir}" + while IFS= read -r platform_arg; do + platform_args+=("${platform_arg}") + done < <(docker_platform_args) + + if [ -t 0 ] && [ -t 1 ]; then + tty_args=(-it) + else + tty_args=(-i) + fi + + while IFS= read -r node_modules_target; do + volume_name="${volume_prefix}-$(printf '%s' "${node_modules_target}" | tr '/.' '--')" + node_modules_volume_args+=(-v "${volume_name}:${CONTAINER_REPO_ROOT}/${node_modules_target}") + done < <(cd "${REPO_ROOT}" && get_node_modules_targets) + + # Dedicated named volume for pnpm's content-addressable store. Without this, + # pnpm falls back to /.pnpm-store inside the bind-mounted repo, + # leaking a multi-GB store directory onto the host. + node_modules_volume_args+=(-v "${volume_prefix}---pnpm-store:${CONTAINER_REPO_ROOT}/.pnpm-store") + + build_runner_image + + prepare_volume_ownership "${uid}" "${gid}" "${node_modules_volume_args[@]}" + + for name in \ + BROWSERS \ + CHANGEDFILEINFOSPATH \ + CI_ENV \ + CONCURRENCY \ + CONSTEL \ + DEBUG \ + DISABLE_DEMO_TEST_SETTINGS \ + NODE_OPTIONS \ + NX_SKIP_NX_CACHE \ + PLAYWRIGHT_DOCKER_BUILD \ + PLAYWRIGHT_DOCKER_INSTALL \ + PLAYWRIGHT_DOCKER_RESTORE_DEMOS \ + PLAYWRIGHT_GREP \ + PLAYWRIGHT_LEGACY_SCREENSHOT \ + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD \ + STRATEGY \ + TCQUARANTINE \ + THEME; do + if [ -n "${!name+x}" ]; then + env_args+=(-e "${name}=${!name}") + fi + done + + log "Running Playwright common tests in Docker." + docker run --rm "${tty_args[@]}" --init \ + "${platform_args[@]}" \ + --shm-size="${PLAYWRIGHT_DOCKER_SHM_SIZE:-2g}" \ + --user "${uid}:${gid}" \ + -e "HOME=/home/devextreme" \ + -e "USER=devextreme" \ + -e "HOST_UID=${uid}" \ + -e "HOST_GID=${gid}" \ + "${env_args[@]}" \ + -v "${REPO_ROOT}:${CONTAINER_REPO_ROOT}" \ + -v "${home_dir}:/home/devextreme" \ + "${node_modules_volume_args[@]}" \ + -w "${CONTAINER_REPO_ROOT}" \ + "${runner_image}" \ + bash "${CONTAINER_SCRIPT}" __container "$@" +} + +install_roboto_if_needed() { + if [[ "${THEME}" != *material* ]]; then + return + fi + + local roboto_font_dir="${HOME}/.local/share/fonts/roboto" + local roboto_file="${roboto_font_dir}/Roboto.ttf" + local google_fonts_commit="8b0a1d0f5983c89bc2b93f1b5fb55f9e252744b5" + local roboto_sha256="d7598e12c5dbef095ff8272cfc55da0250bd07fbdecbac8a530b9b277872a134" + local base_url="https://raw.githubusercontent.com/google/fonts/${google_fonts_commit}/ofl/roboto" + + mkdir -p "${roboto_font_dir}" + + if [ ! -f "${roboto_file}" ]; then + log "Installing pinned Roboto font for Material theme." + curl -fsSL "${base_url}/Roboto%5Bwdth%2Cwght%5D.ttf" -o /tmp/Roboto.ttf + echo "${roboto_sha256} /tmp/Roboto.ttf" | sha256sum -c - + mv /tmp/Roboto.ttf "${roboto_file}" + fi + + fc-cache -f > /dev/null 2>&1 +} + +get_chrome_flags() { + local flags + + flags="chrome:headless --window-size=1200,800 --disable-gpu --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" + + if [[ "${THEME}" != *material* ]]; then + flags="${flags} --font-render-hinting=none --disable-font-subpixel-positioning" + fi + + printf '%s\n' "${flags}" +} + +snapshot_demo_indexes() { + local snapshot_tar="$1" + + if ! is_truthy "${PLAYWRIGHT_DOCKER_RESTORE_DEMOS:-true}"; then + return + fi + + git ls-files -z -- \ + 'apps/demos/Demos/**/jQuery/index.html' \ + 'apps/demos/Demos/**/React/index.html' \ + 'apps/demos/Demos/**/Vue/index.html' \ + 'apps/demos/Demos/**/Angular/index.html' \ + | tar --null -T - -cf "${snapshot_tar}" +} + +restore_demo_indexes() { + local snapshot_tar="$1" + local baseline_dirty="$2" + local changed_file + + if ! is_truthy "${PLAYWRIGHT_DOCKER_RESTORE_DEMOS:-true}" || [ ! -f "${snapshot_tar}" ]; then + return + fi + + while IFS= read -r changed_file; do + if grep -Fxq "${changed_file}" "${baseline_dirty}"; then + log "Keeping pre-existing demo change: ${changed_file}" + continue + fi + + if tar -tf "${snapshot_tar}" "${changed_file}" > /dev/null 2>&1; then + tar -xf "${snapshot_tar}" "${changed_file}" + log "Restored generated demo theme change: ${changed_file}" + fi + done < <(git diff --name-only -- \ + 'apps/demos/Demos/**/jQuery/index.html' \ + 'apps/demos/Demos/**/React/index.html' \ + 'apps/demos/Demos/**/Vue/index.html' \ + 'apps/demos/Demos/**/Angular/index.html') +} + +run_container() { + local server_pid="" + local test_status=0 + local tmp_dir + local snapshot_tar + local baseline_dirty + local playwright_target + local -a test_args=() + + cd "${CONTAINER_REPO_ROOT}" + + export CI=true + export CI_ENV="${CI_ENV:-true}" + export CONCURRENCY="${CONCURRENCY:-4}" + export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=8192}" + export NX_SKIP_NX_CACHE="${NX_SKIP_NX_CACHE:-true}" + # Pin pnpm's content-addressable store to the dedicated named volume mounted + # at /.pnpm-store. Without this, pnpm picks a HOME-relative default + # that ends up on a different filesystem than the workspace and falls back + # to writing the store under /.pnpm-store on the bind mount, leaking + # multi-GB of cache onto the host. + export NPM_CONFIG_STORE_DIR="${NPM_CONFIG_STORE_DIR:-${CONTAINER_REPO_ROOT}/.pnpm-store}" + # Match the CI workflow: opt out of Playwright's CDPScreenshotNewSurface so + # screenshots match TestCafe-generated etalons (esp. for Material text). + export PLAYWRIGHT_LEGACY_SCREENSHOT="${PLAYWRIGHT_LEGACY_SCREENSHOT:-1}" + export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD="${PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD:-1}" + export STRATEGY="${STRATEGY:-screenshots}" + export TCQUARANTINE="${TCQUARANTINE:-true}" + export THEME="${THEME:-fluent.blue.light}" + export BROWSERS="${BROWSERS:-$(get_chrome_flags)}" + + if [ "${STRATEGY}" = "accessibility" ]; then + export CONSTEL="${CONSTEL:-jquery}" + playwright_target="test-playwright-common-accessibility" + elif [ "${STRATEGY}" = "widgets" ]; then + export CONSTEL="${CONSTEL:-jquery(1/3)}" + playwright_target="test-playwright-widgets-screenshots" + else + export CONSTEL="${CONSTEL:-jquery(1/3)}" + playwright_target="test-playwright-common-screenshots" + fi + + tmp_dir="$(mktemp -d)" + snapshot_tar="${tmp_dir}/demo-indexes.tar" + baseline_dirty="${tmp_dir}/dirty-demo-files.txt" + + cleanup() { + local cleanup_status=$? + + if [ -n "${server_pid:-}" ] && kill -0 "${server_pid}" > /dev/null 2>&1; then + kill "${server_pid}" > /dev/null 2>&1 || true + wait "${server_pid}" > /dev/null 2>&1 || true + fi + + cd "${CONTAINER_REPO_ROOT}" || true + restore_demo_indexes "${snapshot_tar:-}" "${baseline_dirty:-}" || true + rm -rf "${tmp_dir:-}" + + exit "${cleanup_status}" + } + trap cleanup EXIT + + git status --short -- \ + 'apps/demos/Demos/**/jQuery/index.html' \ + 'apps/demos/Demos/**/React/index.html' \ + 'apps/demos/Demos/**/Vue/index.html' \ + 'apps/demos/Demos/**/Angular/index.html' \ + | sed -E 's/^.. //' > "${baseline_dirty}" || true + snapshot_demo_indexes "${snapshot_tar}" || true + + mise trust "${CONTAINER_REPO_ROOT}/.mise.toml" > /dev/null 2>&1 || true + mise install + + log "Chrome: $(google-chrome-stable --version)" + log "Node/pnpm: $(mise exec -- node --version) / $(mise exec -- pnpm --version)" + log "STRATEGY=${STRATEGY}" + log "CONSTEL=${CONSTEL}" + log "THEME=${THEME}" + + if ! is_truthy "${PLAYWRIGHT_DOCKER_INSTALL:-true}"; then + log "Skipping pnpm install because PLAYWRIGHT_DOCKER_INSTALL=false." + else + log "Installing dependencies." + mise exec -- pnpm install --frozen-lockfile + fi + + if [ "${PLAYWRIGHT_DOCKER_BUILD:-always}" = "skip" ]; then + log "Skipping DevExtreme testing build because PLAYWRIGHT_DOCKER_BUILD=skip." + else + log "Building DevExtreme testing artifacts." + mise exec -- pnpm exec nx build devextreme-scss + mise exec -- pnpm exec nx build devextreme -c testing + fi + + install_roboto_if_needed + + log "Starting repo-root web server on port 8080." + mkdir -p apps/demos/testing/artifacts + python3 -m http.server 8080 > apps/demos/testing/artifacts/playwright-docker-http-server.log 2>&1 & + server_pid=$! + sleep 1 + + if [ -n "${PLAYWRIGHT_GREP:-}" ]; then + test_args+=(--grep "${PLAYWRIGHT_GREP}") + fi + test_args+=("$@") + + log "Running Playwright ${STRATEGY}." + cd "${CONTAINER_REPO_ROOT}/apps/demos" + set +e + if [ "${#test_args[@]}" -gt 0 ]; then + mise exec -- pnpm exec nx "${playwright_target}" -- "${test_args[@]}" + test_status=$? + else + mise exec -- pnpm exec nx "${playwright_target}" + test_status=$? + fi + set -e + + if [ "${STRATEGY}" = "accessibility" ]; then + log "Axe reports: apps/demos/testing/artifacts/axe-reports" + else + log "Screenshots: apps/demos/testing/screenshots" + log "Compared artifacts: apps/demos/testing/artifacts/compared-screenshots" + fi + log "Playwright report: apps/demos/testing/artifacts/playwright-report" + + return "${test_status}" +} + +if [ "${1:-}" = "__container" ]; then + shift + run_container "$@" +else + run_host "$@" +fi diff --git a/apps/demos/utils/visual-tests/playwright/manual-test-adapter.ts b/apps/demos/utils/visual-tests/playwright/manual-test-adapter.ts new file mode 100644 index 000000000000..9a1c13f5749c --- /dev/null +++ b/apps/demos/utils/visual-tests/playwright/manual-test-adapter.ts @@ -0,0 +1,890 @@ +import { expect, test as playwrightTest } from '@playwright/test'; +import type { ElementHandle, Locator, Page } from '@playwright/test'; +import { glob } from 'glob'; +import Module, { createRequire } from 'module'; +import { join, resolve } from 'path'; + +import { + addClientScripts, + DEMOS_ROOT, + execCode, + resetPageState, + waitForAngularLoading, + waitForStableRendering, +} from './common-screenshots-utils'; +import { + changeTheme, + createMatrixTestSettings, + FRAMEWORKS, + globalReadFrom, + injectStyle, + shouldRunTestAtIndex, + shouldRunTestExplicitly, + THEME, + updateMatrixTestSettings, +} from '../matrix-test-helper-core'; +import { gitHubIgnored } from '../github-ignored-list'; + +type FrameworkKey = keyof typeof FRAMEWORKS; +type FixtureContext = { initialWindowSize?: [number, number] }; +type FixtureState = { name: string; ctx: FixtureContext }; +type TestCallback = (t: TestCafeControllerAdapter) => Promise | void; +type PlaywrightTestRegister = ( + title: string, + callback: (args: { page: Page }) => Promise, +) => void; +type ModuleLoader = (request: string, parent: unknown, isMain: boolean) => unknown; +type TestRegistration = ((name: string, callback: TestCallback) => void) & { + only: (name: string, callback: TestCallback) => void; + skip: (name: string, callback: TestCallback) => void; +}; + +interface ClientScript { + module?: string; + path?: string; + content?: string; +} + +interface ActionOptions { + offsetX?: number; + offsetY?: number; + modifiers?: { + shift?: boolean; + ctrl?: boolean; + alt?: boolean; + meta?: boolean; + }; + replace?: boolean; +} + +const nodeRequire = createRequire(__filename); + +type SelectorBase = string | (() => Element | null) | TestCafeSelector; + +type SelectorOperation = + | { type: 'nth'; index: number } + | { type: 'find'; selector: string } + | { type: 'child'; selector?: string } + | { type: 'withText'; text: string } + | { type: 'withExactText'; text: string } + | { type: 'withAttribute'; name: string; value?: string }; + +class SelectorValue { + constructor( + private readonly selector: TestCafeSelector, + private readonly property: 'exists' | 'count' | 'textContent' | 'hasClass', + private readonly expectedClass?: string, + ) {} + + async evaluate(page: Page): Promise { + const locator = this.selector.toLocator(page); + + switch (this.property) { + case 'exists': + return (await locator.count()) > 0; + case 'count': + return locator.count(); + case 'textContent': + return locator.textContent(); + case 'hasClass': + return locator.evaluate( + (element, className) => element.classList.contains(className), + this.expectedClass || '', + ); + default: + return undefined; + } + } +} + +class TestCafeSelector { + private readonly base: Exclude; + + private readonly operations: SelectorOperation[]; + + constructor(base: SelectorBase, operations: SelectorOperation[] = []) { + if (base instanceof TestCafeSelector) { + this.base = base.base; + this.operations = [...base.operations, ...operations]; + return; + } + + this.base = base; + this.operations = operations; + } + + nth(index: number): TestCafeSelector { + return new TestCafeSelector(this, [{ type: 'nth', index }]); + } + + find(selector: string): TestCafeSelector { + return new TestCafeSelector(this, [{ type: 'find', selector }]); + } + + child(selector?: string): TestCafeSelector { + return new TestCafeSelector(this, [{ type: 'child', selector }]); + } + + withText(text: string): TestCafeSelector { + return new TestCafeSelector(this, [{ type: 'withText', text }]); + } + + withExactText(text: string): TestCafeSelector { + return new TestCafeSelector(this, [{ type: 'withExactText', text }]); + } + + withAttribute(name: string, value?: string): TestCafeSelector { + return new TestCafeSelector(this, [{ type: 'withAttribute', name, value }]); + } + + hasClass(className: string): SelectorValue { + return new SelectorValue(this, 'hasClass', className); + } + + get exists(): SelectorValue { + return new SelectorValue(this, 'exists'); + } + + get count(): SelectorValue { + return new SelectorValue(this, 'count'); + } + + get textContent(): SelectorValue { + return new SelectorValue(this, 'textContent'); + } + + toLocator(page: Page): Locator { + if (typeof this.base === 'function') { + throw new Error('Function selectors cannot be converted to a Playwright locator'); + } + + let cssSelector = this.base; + let locator = page.locator(cssSelector); + + for (const operation of this.operations) { + switch (operation.type) { + case 'find': + cssSelector = `${cssSelector} ${operation.selector}`; + locator = page.locator(cssSelector); + break; + case 'child': + cssSelector = `${cssSelector} > ${operation.selector || '*'}`; + locator = page.locator(cssSelector); + break; + case 'withAttribute': + cssSelector = `${cssSelector}[${operation.name}${operation.value === undefined ? '' : `="${operation.value}"`}]`; + locator = page.locator(cssSelector); + break; + case 'nth': + locator = operation.index === -1 + ? locator.last() + : locator.nth(operation.index); + break; + case 'withText': + locator = locator.filter({ hasText: operation.text }); + break; + case 'withExactText': + locator = locator.filter({ hasText: RegExp(`^${escapeRegExp(operation.text)}$`) }); + break; + default: + break; + } + } + + return locator; + } + + async resolveForAction(page: Page): Promise> { + if (typeof this.base !== 'function') { + // TestCafe actions always operate on the first matching element when + // multiple elements match a selector (non-strict by default). Use + // .first() so the Playwright adapter replicates that behaviour instead + // of failing with a strict-mode violation. + return this.toLocator(page).first(); + } + + if (this.operations.length) { + throw new Error('Function selectors do not support chained selector operations'); + } + + const handle = await page.evaluateHandle(this.base); + const element = handle.asElement(); + + if (!element) { + throw new Error('Function selector did not resolve to an element'); + } + + return element; + } +} + +class AssertionAdapter { + constructor( + private readonly controller: TestCafeControllerAdapter, + private readonly actual: unknown, + ) {} + + ok(message?: string): TestCafeControllerAdapter { + return this.controller.enqueue(async () => { + const actual = await this.controller.resolveActual(this.actual); + + if (actual === undefined) { + return; + } + + expect(Boolean(actual), message).toBe(true); + }); + } + + notOk(message?: string): TestCafeControllerAdapter { + return this.controller.enqueue(async () => { + const actual = await this.controller.resolveActual(this.actual); + expect(Boolean(actual), message).toBe(false); + }); + } + + eql(expected: unknown, message?: string): TestCafeControllerAdapter { + return this.controller.enqueue(async () => { + const actual = await this.controller.resolveActual(this.actual); + expect(actual, message).toEqual(expected); + }); + } +} + +class TestCafeControllerAdapter implements PromiseLike { + readonly fixtureCtx: FixtureContext; + + readonly testRun: { + opts: { + 'screenshots-comparer': Record; + disableScreenshots: boolean; + }; + test: { + testFile: { + filename: string; + }; + }; + }; + + private chain: Promise = Promise.resolve(); + + constructor( + private readonly page: Page, + testFilePath: string, + fixtureContext: FixtureContext, + ) { + this.fixtureCtx = fixtureContext; + this.testRun = { + opts: { + 'screenshots-comparer': { + path: join(DEMOS_ROOT, 'testing'), + screenshotsRelativePath: '/screenshots', + destinationRelativePath: '/artifacts/compared-screenshots', + ...(isMaterialTheme() ? { textMaskRadius: 2 } : {}), + }, + disableScreenshots: false, + }, + test: { + testFile: { + filename: testFilePath, + }, + }, + }; + } + + then( + onfulfilled?: ((value: undefined) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): PromiseLike { + return this.flush().then(onfulfilled, onrejected); + } + + enqueue(action: () => Promise): TestCafeControllerAdapter { + this.chain = this.chain.then(action); + return this; + } + + async flush(): Promise { + await this.chain; + } + + resolveActual(actual: unknown): Promise { + if (actual instanceof SelectorValue) { + return actual.evaluate(this.page); + } + + return actual instanceof Promise + ? actual + : Promise.resolve(actual); + } + + expect(actual: unknown): AssertionAdapter { + return new AssertionAdapter(this, actual); + } + + click(selector: SelectorBase, options: ActionOptions = {}): TestCafeControllerAdapter { + return this.enqueue(async () => { + const target = await this.resolveForAction(selector); + const actionOptions = toPlaywrightActionOptions(options); + + if ('click' in target) { + // TestCafe does not check for overlay elements intercepting the click. + // force:true replicates that: Playwright still checks visibility but + // skips the overlay/actionability check, matching TestCafe behaviour. + await target.click({ ...actionOptions, force: true }); + } + }); + } + + doubleClick(selector: SelectorBase, options: ActionOptions = {}): TestCafeControllerAdapter { + return this.enqueue(async () => { + const target = await this.resolveForAction(selector); + const actionOptions = toPlaywrightActionOptions(options); + + if ('dblclick' in target) { + await target.dblclick(actionOptions); + } + }); + } + + rightClick(selector: SelectorBase, options: ActionOptions = {}): TestCafeControllerAdapter { + return this.enqueue(async () => { + const target = await this.resolveForAction(selector); + + if ('click' in target) { + await target.click({ + ...toPlaywrightActionOptions(options), + button: 'right', + }); + } + }); + } + + hover(selector: SelectorBase, options: ActionOptions = {}): TestCafeControllerAdapter { + return this.enqueue(async () => { + const target = await this.resolveForAction(selector); + + if ('hover' in target) { + await target.hover(toPlaywrightActionOptions(options)); + } + }); + } + + typeText(selector: SelectorBase, text: string, options: ActionOptions = {}): TestCafeControllerAdapter { + return this.enqueue(async () => { + const locator = toLocator(await this.resolveForAction(selector)); + + if (options.replace) { + await locator.fill(text); + return; + } + + await locator.pressSequentially(text); + }); + } + + pressKey(keys: string): TestCafeControllerAdapter { + return this.enqueue(async () => { + for (const key of keys.split(/\s+/).filter(Boolean)) { + await this.page.keyboard.press(toPlaywrightKey(key)); + } + }); + } + + drag(selector: SelectorBase, dragOffsetX: number, dragOffsetY: number, options: ActionOptions = {}): TestCafeControllerAdapter { + return this.enqueue(async () => { + const locator = toLocator(await this.resolveForAction(selector)); + const box = await locator.boundingBox(); + + if (!box) { + throw new Error('Unable to drag an invisible element'); + } + + const startX = box.x + (options.offsetX ?? box.width / 2); + const startY = box.y + (options.offsetY ?? box.height / 2); + + await this.page.mouse.move(startX, startY); + await this.page.mouse.down(); + await this.page.mouse.move(startX + dragOffsetX, startY + dragOffsetY, { steps: 12 }); + await this.page.mouse.up(); + }); + } + + dragToElement(selector: SelectorBase, destination: SelectorBase): TestCafeControllerAdapter { + return this.enqueue(async () => { + const sourceLocator = toLocator(await this.resolveForAction(selector)); + const destinationLocator = toLocator(await this.resolveForAction(destination)); + const sourceBox = await sourceLocator.boundingBox(); + const destinationBox = await destinationLocator.boundingBox(); + + if (!sourceBox || !destinationBox) { + throw new Error('Unable to drag an invisible element'); + } + + await this.page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2); + await this.page.mouse.down(); + await this.page.mouse.move( + destinationBox.x + destinationBox.width / 2, + destinationBox.y + destinationBox.height / 2, + { steps: 12 }, + ); + await this.page.mouse.up(); + }); + } + + scrollBy(selector: SelectorBase, x: number, y: number): TestCafeControllerAdapter { + return this.enqueue(async () => { + const locator = toLocator(await this.resolveForAction(selector)); + await locator.evaluate((element, scroll) => { + element.scrollBy(scroll.x, scroll.y); + }, { x, y }); + }); + } + + resizeWindow(width: number, height: number): TestCafeControllerAdapter { + return this.enqueue(async () => { + await this.page.setViewportSize({ width, height }); + }); + } + + wait(timeout: number): TestCafeControllerAdapter { + return this.enqueue(async () => { + await this.page.waitForTimeout(timeout); + }); + } + + eval(callback: () => T | Promise): Promise { + return this.page.evaluate(callback); + } + + async takeScreenshot(filePath: string): Promise { + await this.flush(); + await waitForStableRendering(this.page); + await this.page.screenshot({ + path: filePath, + fullPage: false, + }); + } + + async takeElementScreenshot(selector: SelectorBase, filePath: string): Promise { + await this.flush(); + await waitForStableRendering(this.page); + const target = await this.resolveForAction(selector); + + await target.screenshot({ path: filePath }); + } + + async evaluateClientFunction( + callback: (...args: unknown[]) => T, + args: unknown[], + dependencies: Record, + ): Promise { + await this.flush(); + return this.page.evaluate( + ({ source, dependencyValues, functionArgs }) => { + const names = Object.keys(dependencyValues); + const values = Object.values(dependencyValues); + // eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval + const factory = new Function( + ...names, + 'functionArgs', + `return (${source})(...functionArgs);`, + ); + + return factory(...values, functionArgs); + }, + { + source: callback.toString(), + dependencyValues: dependencies, + functionArgs: args, + }, + ) as Promise; + } + + private resolveForAction(selector: SelectorBase): Promise> { + if (selector instanceof TestCafeSelector) { + return selector.resolveForAction(this.page); + } + + if (typeof selector === 'string') { + return Promise.resolve(this.page.locator(selector)); + } + + return new TestCafeSelector(selector).resolveForAction(this.page); + } +} + +const settings = createMatrixTestSettings({ includeManualTests: true }); +const DEFAULT_WINDOW_SIZE: [number, number] = [900, 600]; +const SKIPPED_TESTS: Record> = { + jQuery: { DataGrid: ['RemoteGrouping'] }, + Angular: { DataGrid: ['RemoteGrouping'] }, + Vue: { DataGrid: ['RemoteGrouping'] }, + React: { DataGrid: ['RemoteGrouping'] }, +}; + +let manualTestIndex = 0; +let adaptersInstalled = false; +let currentFixture: FixtureState | null = null; +let currentTestFilePath = ''; +let activeController: TestCafeControllerAdapter | null = null; + +function isMaterialTheme(theme = process.env.THEME || ''): boolean { + return theme.startsWith('material'); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&'); +} + +function toLocator(target: Locator | ElementHandle): Locator { + if ('locator' in target) { + return target; + } + + throw new Error('This action requires a locator-backed selector'); +} + +function toPlaywrightActionOptions(options: ActionOptions): { + position?: { x: number; y: number }; + modifiers?: ('Alt' | 'Control' | 'Meta' | 'Shift')[]; +} { + const modifiers: ('Alt' | 'Control' | 'Meta' | 'Shift')[] = []; + + if (options.modifiers?.alt) modifiers.push('Alt'); + if (options.modifiers?.ctrl) modifiers.push('Control'); + if (options.modifiers?.meta) modifiers.push('Meta'); + if (options.modifiers?.shift) modifiers.push('Shift'); + + return { + ...(options.offsetX !== undefined || options.offsetY !== undefined + ? { position: { x: Math.max(0, options.offsetX || 0), y: Math.max(0, options.offsetY || 0) } } + : {}), + ...(modifiers.length ? { modifiers } : {}), + }; +} + +function toPlaywrightKey(testCafeKey: string): string { + return testCafeKey + .split('+') + .map((part) => { + const normalized = part.toLowerCase(); + + switch (normalized) { + case 'ctrl': + return 'Control'; + case 'alt': + return 'Alt'; + case 'shift': + return 'Shift'; + case 'meta': + return 'Meta'; + case 'esc': + return 'Escape'; + case 'right': + return 'ArrowRight'; + case 'left': + return 'ArrowLeft'; + case 'up': + return 'ArrowUp'; + case 'down': + return 'ArrowDown'; + case 'tab': + return 'Tab'; + case 'enter': + return 'Enter'; + default: + return part; + } + }) + .join('+'); +} + +function createFixture(name: string): { + before: (callback: (ctx: FixtureContext) => void | Promise) => unknown; +} { + const fixtureState: FixtureState = { name, ctx: {} }; + currentFixture = fixtureState; + + return { + before: (callback) => { + callback(fixtureState.ctx); + return fixtureState; + }, + }; +} + +function getDemosBaseUrl(): string { + return (process.env.PLAYWRIGHT_DEMOS_BASE_URL || 'http://127.0.0.1:8080').replace(/\/$/, ''); +} + +function getManualTestStyles(demoName: string): string { + switch (demoName) { + case 'EditorAppearanceVariants': + return '.dx-toast-wrapper { display: none !important; }'; + case 'VirtualScrolling': + case 'StatePersistence': + case 'EditStateManagement': + case 'BatchUpdateRequest': + return '.dx-scrollable-scroll { visibility: visible !important; }'; + default: + return ''; + } +} + +function shouldSkipManualDemo(framework: string, widget: string, demo: string): boolean { + return SKIPPED_TESTS[framework]?.[widget]?.includes(demo) || false; +} + +function getManualTestUrl( + widget: string, + demo: string, + approach: string, + options: { updateTheme?: boolean } = {}, +): string | null { + const baseUrl = getDemosBaseUrl(); + + if (process.env.ISGITHUBDEMOS) { + if (widget !== 'DataGrid' || gitHubIgnored.includes(demo)) { + return null; + } + + const theme = (process.env.THEME || THEME.generic).replace('generic.', ''); + return `${baseUrl}/${widget}/${demo}/${approach}/?theme=dx.${theme}`; + } + + if (options.updateTheme) { + changeTheme(DEMOS_ROOT, `Demos/${widget}/${demo}/${approach}/index.html`, process.env.THEME); + } + + return `${baseUrl}/apps/demos/Demos/${widget}/${demo}/${approach}/`; +} + +async function prepareManualTestPage( + page: Page, + widget: string, + demo: string, + approach: string, + fixtureContext: FixtureContext, +): Promise { + const pageUrl = getManualTestUrl(widget, demo, approach, { updateTheme: true }); + if (!pageUrl) { + return null; + } + + const [width, height] = fixtureContext.initialWindowSize || DEFAULT_WINDOW_SIZE; + const clientScriptSource = globalReadFrom( + DEMOS_ROOT, + `Demos/${widget}/${demo}/client-script.js`, + (source) => [{ content: source }], + ) || []; + const testCodeSource = globalReadFrom( + DEMOS_ROOT, + `Demos/${widget}/${demo}/test-code.js`, + (source) => source, + ) || ''; + const testStyles = getManualTestStyles(demo); + const globalTestStyles = globalReadFrom( + DEMOS_ROOT, + 'utils/visual-tests/inject/test-styles.css', + (source) => source, + ) || ''; + + const clientScripts: (ClientScript | string)[] = [ + { module: 'mockdate' }, + join(DEMOS_ROOT, 'utils/visual-tests/inject/test-utils.js'), + { content: injectStyle(globalTestStyles) }, + ...(testStyles ? [{ content: injectStyle(testStyles) }] : []), + ...(clientScriptSource as ClientScript[]), + ]; + + await page.setViewportSize({ width, height }); + await addClientScripts(page, clientScripts); + await page.goto(pageUrl); + await resetPageState(page); + + if (testCodeSource) { + await execCode(page, testCodeSource); + } + + if (approach === FRAMEWORKS.angular) { + await waitForAngularLoading(page); + } + + return pageUrl; +} + +function registerManualTest( + register: PlaywrightTestRegister, + title: string, + callback: TestCallback, + widget: string, + demo: string, + approach: string, + fixtureContext: FixtureContext, + testFilePath: string, +): void { + register(title, async ({ page }) => { + const pageUrl = await prepareManualTestPage(page, widget, demo, approach, fixtureContext); + if (!pageUrl) { + return; + } + + const controller = new TestCafeControllerAdapter(page, testFilePath, fixtureContext); + activeController = controller; + + try { + await callback(controller); + await controller.flush(); + } finally { + activeController = null; + } + }); +} + +function createTestRegistration( + widget: string, + demo: string, + approach: string, + fixtureContext: FixtureContext, + testFilePath: string, +): TestRegistration { + const register = (name: string, callback: TestCallback): void => registerManualTest( + playwrightTest, + name, + callback, + widget, + demo, + approach, + fixtureContext, + testFilePath, + ); + + register.only = (name: string, callback: TestCallback): void => registerManualTest( + playwrightTest.only as PlaywrightTestRegister, + name, + callback, + widget, + demo, + approach, + fixtureContext, + testFilePath, + ); + register.skip = (name: string, callback: TestCallback): void => registerManualTest( + playwrightTest.skip as PlaywrightTestRegister, + name, + callback, + widget, + demo, + approach, + fixtureContext, + testFilePath, + ); + + return register; +} + +function runManualTest(widget: string, demo: string, callback: (test: TestRegistration) => void): void { + if (process.env.STRATEGY === 'accessibility') { + return; + } + + const frameworkKey = (settings.targetFramework || 'jquery').toLowerCase() as FrameworkKey; + const approach = FRAMEWORKS[frameworkKey]; + + if (!approach || shouldSkipManualDemo(approach, widget, demo)) { + return; + } + + manualTestIndex += 1; + if (!shouldRunTestAtIndex(settings, manualTestIndex)) { + return; + } + + const pageUrl = getManualTestUrl(widget, demo, approach); + if (!pageUrl || !shouldRunTestExplicitly(settings, pageUrl)) { + return; + } + + const fixtureContext = currentFixture?.ctx || {}; + const fixtureName = currentFixture?.name || `${widget}.${demo}`; + const testFilePath = currentTestFilePath; + + playwrightTest.describe(`${approach} ${fixtureName}`, () => { + callback(createTestRegistration( + widget, + demo, + approach, + fixtureContext, + testFilePath, + )); + }); +} + +function clientFunction any>( + callback: T, + options: { dependencies?: Record } = {}, +): (...args: Parameters) => Promise> { + return (...args: Parameters) => { + if (!activeController) { + return Promise.reject(new Error('ClientFunction was called outside of a Playwright manual test')); + } + + return activeController.evaluateClientFunction( + callback, + args, + options.dependencies || {}, + ) as Promise>; + }; +} + +const testCafeShim = { + Selector: (selector: SelectorBase): TestCafeSelector => new TestCafeSelector(selector), + ClientFunction: clientFunction, +}; + +const matrixHelperShim = { + runManualTest, + FRAMEWORKS, +}; + +export function installManualTestAdapters(): void { + if (adaptersInstalled) { + return; + } + + updateMatrixTestSettings(settings); + manualTestIndex = settings.current ? settings.current - 1 : 0; + adaptersInstalled = true; + + // eslint-disable-next-line no-undef + (globalThis as typeof globalThis & { fixture?: typeof createFixture }).fixture = createFixture; + + const loadableModule = Module as typeof Module & { _load: ModuleLoader }; + const originalLoad = loadableModule._load; + loadableModule._load = function load(request: string, parent: unknown, isMain: boolean) { + if (request === 'testcafe') { + return testCafeShim; + } + + if (request.includes('utils/visual-tests/matrix-test-helper')) { + return matrixHelperShim; + } + + return originalLoad.apply(this, [request, parent, isMain]); + }; +} + +export function loadWidgetTests(): void { + const widgetTestFiles = glob.sync('testing/widgets/**/*.test.ts', { + cwd: DEMOS_ROOT, + absolute: true, + }).sort(); + + for (const testFile of widgetTestFiles) { + currentTestFilePath = resolve(testFile); + nodeRequire(currentTestFilePath); + } + + currentTestFilePath = ''; +} diff --git a/apps/demos/utils/visual-tests/playwright/manual-widgets.spec.ts b/apps/demos/utils/visual-tests/playwright/manual-widgets.spec.ts new file mode 100644 index 000000000000..a83f2c72f257 --- /dev/null +++ b/apps/demos/utils/visual-tests/playwright/manual-widgets.spec.ts @@ -0,0 +1,4 @@ +import { installManualTestAdapters, loadWidgetTests } from './manual-test-adapter'; + +installManualTestAdapters(); +loadWidgetTests(); diff --git a/apps/demos/utils/visual-tests/playwright/screenshot-comparer.ts b/apps/demos/utils/visual-tests/playwright/screenshot-comparer.ts new file mode 100644 index 000000000000..fe8923ef9966 --- /dev/null +++ b/apps/demos/utils/visual-tests/playwright/screenshot-comparer.ts @@ -0,0 +1,115 @@ +import { join } from 'path'; +import { createScreenshotsComparer } from 'devextreme-screenshot-comparer'; +import type { IComparerOptions, SelectorType } from 'devextreme-screenshot-comparer/build/src/options'; +import type { Page } from '@playwright/test'; + +import { DEMOS_ROOT, THEME } from './common-screenshots-utils'; + +type ScreenshotComparerOptions = Partial; + +interface ScreenshotComparisonResult { + isValid: boolean; + errorMessage: string; +} + +interface TestControllerAdapter { + testRun: { + opts: { + 'screenshots-comparer': ScreenshotComparerOptions; + disableScreenshots: boolean; + }; + test: { + testFile: { + filename: string; + }; + }; + }; + eval: (callback: () => T | Promise) => Promise; + wait: (timeout: number) => Promise; + takeScreenshot: (filePath: string) => Promise; + takeElementScreenshot: (element: SelectorType, filePath: string) => Promise; +} + +function getScreenshotName(baseName: string, theme: string = THEME.fluent): string { + const themePostfix = ` (${theme})`; + return baseName.endsWith('.png') + ? baseName.replace('.png', `${themePostfix}.png`) + : `${baseName}${themePostfix}.png`; +} + +function isMaterialTheme(theme = process.env.THEME || ''): boolean { + return theme.startsWith('material'); +} + +function getComparerOptions( + comparisonOptions?: ScreenshotComparerOptions, +): ScreenshotComparerOptions { + // Mirror apps/demos/utils/visual-tests/helpers/theme-utils.js#testScreenshot + // so the Playwright comparer applies the same tolerances and text-mask + // threshold for every theme (fluent and material). Material is the most + // visible offender because of Roboto antialiasing, but fluent diffs are + // sensitive to the same defaults — both fail without this parity. + return { + ...comparisonOptions, + textDiffTreshold: 0.2, + // Residual Playwright/Roboto CI diffs are confined to Material glyph edges. + ...(isMaterialTheme() ? { textMaskRadius: 2 } : {}), + path: join(DEMOS_ROOT, 'testing'), + screenshotsRelativePath: '/screenshots', + destinationRelativePath: '/artifacts/compared-screenshots', + looksSameComparisonOptions: { + ...comparisonOptions?.looksSameComparisonOptions, + tolerance: 20, + antialiasingTolerance: 20, + }, + }; +} + +function createTestCafeAdapter( + page: Page, + comparisonOptions?: ScreenshotComparerOptions, +): TestControllerAdapter { + return { + testRun: { + opts: { + 'screenshots-comparer': getComparerOptions(comparisonOptions), + disableScreenshots: false, + }, + test: { + testFile: { + filename: join(DEMOS_ROOT, 'testing/common.test.ts'), + }, + }, + }, + eval: (callback) => page.evaluate(callback), + wait: (timeout) => page.waitForTimeout(timeout), + takeScreenshot: (filePath) => page.screenshot({ + path: filePath, + fullPage: false, + }).then(() => {}), + takeElementScreenshot: async (element, filePath) => { + if (typeof element !== 'string') { + throw new Error('Playwright screenshot comparer adapter supports only string selectors'); + } + + await page.locator(element).screenshot({ path: filePath }); + }, + }; +} + +export async function compareDemoScreenshot( + page: Page, + screenshotName: string, + comparisonOptions?: ScreenshotComparerOptions, +): Promise { + const finalScreenshotName = getScreenshotName(screenshotName, process.env.THEME || THEME.fluent); + const testController = createTestCafeAdapter(page, comparisonOptions); + const { takeScreenshot, compareResults } = createScreenshotsComparer(testController as never); + + await takeScreenshot(finalScreenshotName); + + return { + isValid: compareResults.isValid(), + errorMessage: compareResults.errorMessages(), + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9265c3eac3c..2e591493b6dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -662,6 +662,9 @@ importers: '@eslint/js': specifier: 'catalog:' version: 9.39.4 + '@playwright/test': + specifier: 1.60.0 + version: 1.60.0 '@rollup/plugin-babel': specifier: 6.1.0 version: 6.1.0(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@4.59.0) @@ -6630,6 +6633,11 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -11671,6 +11679,11 @@ packages: os: [darwin] deprecated: Upgrade to fsevents v2 to mitigate potential security issues + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -15217,6 +15230,16 @@ packages: resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} engines: {node: '>=16.0.0'} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + plimit-lit@1.6.1: resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==} engines: {node: '>=12'} @@ -25206,6 +25229,10 @@ snapshots: '@pkgr/core@0.2.9': {} + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + '@popperjs/core@2.11.8': {} '@preact/signals-core@1.14.1': {} @@ -32120,6 +32147,9 @@ snapshots: nan: 2.26.2 optional: true + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -37504,6 +37534,14 @@ snapshots: pvutils: 1.1.5 tslib: 2.8.1 + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + plimit-lit@1.6.1: dependencies: queue-lit: 1.5.2