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