diff --git a/.github/workflows/wrapper_tests_e2e.yml b/.github/workflows/wrapper_tests_e2e.yml index fff07fe970d1..336fedc81790 100644 --- a/.github/workflows/wrapper_tests_e2e.yml +++ b/.github/workflows/wrapper_tests_e2e.yml @@ -170,12 +170,6 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Start server for ${{ matrix.framework }} - working-directory: e2e/wrappers - run: | - pnpm run start:${{ matrix.framework }} & - sleep 10 - - name: Run tests for ${{ matrix.framework }} working-directory: e2e/wrappers run: pnpm run test:${{ matrix.framework }} @@ -185,5 +179,7 @@ jobs: uses: actions/upload-artifact@v7 with: name: test-fails-${{matrix.framework}} - path: e2e/wrappers/screenshots + path: | + e2e/wrappers/playwright-report + e2e/wrappers/test-results if-no-files-found: ignore diff --git a/apps/react/package.json b/apps/react/package.json index 626d1c0e6cbf..096562318540 100644 --- a/apps/react/package.json +++ b/apps/react/package.json @@ -27,7 +27,6 @@ "webpack-dev-server": "5.2.6" }, "scripts": { - "test": "node runner.js", "start": "webpack-dev-server", "build": "webpack" }, diff --git a/apps/react/project.json b/apps/react/project.json index 42973c8ad028..5dfa1a454156 100644 --- a/apps/react/project.json +++ b/apps/react/project.json @@ -15,7 +15,6 @@ "!{projectRoot}/public/js/app/bundle*", "{projectRoot}/*.tsx", "{projectRoot}/*.js", - "!{projectRoot}/test.js", "{projectRoot}/tsconfig.json", "{workspaceRoot}/tsconfig.json" ], diff --git a/apps/react/runner.js b/apps/react/runner.js deleted file mode 100644 index ed29b45f45c2..000000000000 --- a/apps/react/runner.js +++ /dev/null @@ -1,17 +0,0 @@ -const process = require('process'); -const createTestCafe = require('testcafe'); - -let testCafe; -createTestCafe('127.0.0.1') - .then(tsc => { - testCafe = tsc; - - const runner = tsc.createRunner(); - return runner - .src('test.js') - .browsers('chrome:headless --disable-gpu --window-size=1200,800') - .run(); - }).then(failedCount => { - testCafe.close(); - process.exit(failedCount); - }); diff --git a/apps/react/test.js b/apps/react/test.js deleted file mode 100644 index e7d5cbb051bb..000000000000 --- a/apps/react/test.js +++ /dev/null @@ -1,8 +0,0 @@ -const path = require('path'); - -fixture('DevExtreme React Playground') - .page(path.resolve(__dirname, 'public/index.html')); - -test('App has no errors', async (t) => { - -}); diff --git a/apps/vue/package.json b/apps/vue/package.json index c20c2aa1cf35..b314f15ab54f 100644 --- a/apps/vue/package.json +++ b/apps/vue/package.json @@ -33,7 +33,6 @@ "webpack-dev-server": "5.2.6" }, "scripts": { - "test": "node runner.js", "start": "webpack-dev-server", "build": "webpack" } diff --git a/apps/vue/project.json b/apps/vue/project.json index 0a42ced30f0c..14a07d7f08d6 100644 --- a/apps/vue/project.json +++ b/apps/vue/project.json @@ -16,7 +16,6 @@ "{projectRoot}/*.ts", "{projectRoot}/*.vue", "{projectRoot}/*.js", - "!{projectRoot}/test.js", "{projectRoot}/tsconfig.json" ], "outputs": [ diff --git a/apps/vue/runner.js b/apps/vue/runner.js deleted file mode 100644 index 953758023537..000000000000 --- a/apps/vue/runner.js +++ /dev/null @@ -1,17 +0,0 @@ -const process = require('process'); -const createTestCafe = require('testcafe'); - -let testCafe; -createTestCafe('localhost') - .then(tsc => { - testCafe = tsc; - - const runner = tsc.createRunner(); - return runner - .src('test.js') - .browsers('chrome:headless --disable-gpu --window-size=1200,800') - .run(); - }).then(failedCount => { - testCafe.close(); - process.exit(failedCount); - }); diff --git a/apps/vue/test.js b/apps/vue/test.js deleted file mode 100644 index 5cf6e3127730..000000000000 --- a/apps/vue/test.js +++ /dev/null @@ -1,8 +0,0 @@ -const path = require('path'); - -fixture('DevExtreme Vue Playground') - .page(path.resolve(__dirname, 'public/index.html')); - -test('App has no errors', async (t) => { - -}); diff --git a/e2e/wrappers/.gitignore b/e2e/wrappers/.gitignore new file mode 100644 index 000000000000..5c4ffa21fa5e --- /dev/null +++ b/e2e/wrappers/.gitignore @@ -0,0 +1,2 @@ +playwright-report/ +test-results/ diff --git a/e2e/wrappers/README.md b/e2e/wrappers/README.md new file mode 100644 index 000000000000..e62fb0ef1a70 --- /dev/null +++ b/e2e/wrappers/README.md @@ -0,0 +1,73 @@ +# Wrappers E2E tests + +End-to-end tests that check DevExtreme components inside the React, Vue and Angular wrappers. +The tests are written with [Playwright](https://playwright.dev/) and use the system Google Chrome +(`channel: 'chrome'`), so no browser download is needed. + +## Layout + +| Path | Purpose | +|------------------------|----------------------------------------------------------------------| +| `builders/*` | Host applications that render the examples for each framework | +| `examples/*` | Examples under test, one folder per scenario and framework | +| `tests/*.spec.ts` | Playwright specs | +| `fixtures.ts` | `test`/`expect` with the `framework` option added | +| `playwright.config.ts` | Per-framework port, base URL and dev server | +| `serve.js` | Static server for a built application | +| `docker/` | Container that matches the CI environment | + +## Run locally + +```bash +pnpm install --frozen-lockfile +pnpm nx all:build-testing workflows + +cd e2e/wrappers +pnpm run build:react19 # or build:vue3 / build:angular / build:all + +pnpm run test:react19 # or test:vue3 / test:angular +``` + +`playwright test` starts `serve.js` on its own, so no separate server is needed. +Useful flags: `--headed`, `--debug`, `--ui`, `--reporter=html`. + +Examples that exist for a single framework only (Chat, Gantt) are skipped in the other frameworks. + +## Run in the CI environment + +The container mirrors the OS, Node and Google Chrome the tests get on CI. Dependencies and +application builds are taken from the host: + +```bash +cd e2e/wrappers +docker/run.sh react19 +``` + +To drive the run from the browser on your machine, start the UI mode inside the container and +open `http://localhost:9323`: + +```bash +docker run --rm --platform linux/amd64 --shm-size=2gb -p 9323:9323 \ + -v "$(git rev-parse --show-toplevel):/repo" -w /repo/e2e/wrappers -e FRAMEWORK=react19 \ + devextreme-wrappers-e2e \ + node_modules/.bin/playwright test --ui-host=0.0.0.0 --ui-port=9323 +``` + +The repository is mounted, so edits made on the host are picked up and the watch mode re-runs +the affected tests. Plain `--ui` is not used on purpose: it opens the interface in the bundled +Chromium, which is deliberately not installed. + +Artifacts from a failed CI run are read with the same tooling: + +```bash +pnpm exec playwright show-report /playwright-report +pnpm exec playwright show-trace /test-results//trace.zip --port 0 +``` + +## Rendering + +The default Chrome scrollbars must stay visible: they take layout space, so a page rendered +without them differs from what a user sees and from the etalons the TestCafe tests produce. +Playwright hides scrollbars in headless mode by default, so `--hide-scrollbars` is removed in +`playwright.config.ts`. Measured in the CI-like container, the scrollbar is 15px wide both here +and in TestCafe. diff --git a/e2e/wrappers/docker/Dockerfile b/e2e/wrappers/docker/Dockerfile new file mode 100644 index 000000000000..5b8a844edcd2 --- /dev/null +++ b/e2e/wrappers/docker/Dockerfile @@ -0,0 +1,19 @@ +# Mirrors the environment the tests get on CI: Ubuntu 24.04, Node from .node-version and +# Google Chrome pinned to the version the workflow installs. Keep the font packages in sync +# with the CI runner image — they affect text rendering and, later, screenshot comparison. +FROM ubuntu:24.04 + +ARG NODE_VERSION +ARG CHROME_VERSION + +RUN echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/dx-no-recommends \ + && apt-get update \ + && apt-get install -y ca-certificates curl fonts-noto-color-emoji \ + && 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 \ + && curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.gz" -o /tmp/node.tar.gz \ + && tar -xzf /tmp/node.tar.gz -C /usr/local --strip-components=1 \ + && rm /tmp/chrome.deb /tmp/node.tar.gz \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /repo/e2e/wrappers diff --git a/e2e/wrappers/docker/run.sh b/e2e/wrappers/docker/run.sh new file mode 100755 index 000000000000..426fbcb76cc3 --- /dev/null +++ b/e2e/wrappers/docker/run.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Runs the wrappers e2e tests in a container that matches the CI environment: Node and Google +# Chrome are taken from .node-version and from the workflow that runs these tests on CI. +# The repository is mounted as is, so dependencies must be installed and +# the app under test built on the host first: +# +# pnpm install --frozen-lockfile +# pnpm nx all:build-testing workflows +# cd e2e/wrappers && pnpm run build:react19 +# +# Usage: docker/run.sh [react19|vue3|angular] [extra playwright args] + +set -euo pipefail + +FRAMEWORK="${1:-react19}" +shift || true + +DOCKER_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$DOCKER_DIR/../../.." && pwd)" +WORKFLOW="$REPO_ROOT/.github/workflows/wrapper_tests_e2e.yml" + +NODE_VERSION="$(cat "$REPO_ROOT/.node-version")" +CHROME_VERSION="$(grep -m1 'chrome-version:' "$WORKFLOW" | cut -d: -f2 | tr -d " '\"")" + +if [ -z "$NODE_VERSION" ] || [ -z "$CHROME_VERSION" ]; then + echo "❌ Cannot read the Node version from .node-version or the Chrome version from $WORKFLOW." >&2 + exit 1 +fi + +echo "Node $NODE_VERSION, Google Chrome $CHROME_VERSION" + +# Google Chrome for Linux ships for amd64 only, so the platform is pinned as on CI. +PLATFORM=linux/amd64 + +docker build --platform "$PLATFORM" \ + --build-arg "NODE_VERSION=$NODE_VERSION" \ + --build-arg "CHROME_VERSION=$CHROME_VERSION" \ + -t devextreme-wrappers-e2e "$DOCKER_DIR" + +# Shared memory and seccomp are set as the CI runner container gets them. +docker run --rm --platform "$PLATFORM" --shm-size=2gb --security-opt seccomp=unconfined \ + -v "$REPO_ROOT:/repo" \ + -w /repo/e2e/wrappers \ + -e CI=true \ + -e "FRAMEWORK=$FRAMEWORK" \ + devextreme-wrappers-e2e \ + node_modules/.bin/playwright test "$@" diff --git a/e2e/wrappers/fixtures.ts b/e2e/wrappers/fixtures.ts new file mode 100644 index 000000000000..3ce3100c6f9d --- /dev/null +++ b/e2e/wrappers/fixtures.ts @@ -0,0 +1,13 @@ +import { test as base } from '@playwright/test'; + +export type Framework = 'react19' | 'vue3' | 'angular'; + +export interface TestOptions { + framework: Framework; +} + +export const test = base.extend({ + framework: ['react19', { option: true }], +}); + +export { expect } from '@playwright/test'; diff --git a/e2e/wrappers/package.json b/e2e/wrappers/package.json index 7ba5c5c8644e..e8f3b42f52b3 100644 --- a/e2e/wrappers/package.json +++ b/e2e/wrappers/package.json @@ -13,9 +13,9 @@ "start:vue3": "pnpm run dev:vue3 -- --host", "start:angular": "cd builders/angular && pnpm run start", "build:all": "pnpm run build:react19 && pnpm run build:vue3 && pnpm run build:angular", - "test:react19": "node ./runner.js --framework=react", - "test:vue3": "node ./runner.js --framework=vue", - "test:angular": "node ./runner.js --framework=angular" + "test:react19": "cross-env FRAMEWORK=react19 playwright test", + "test:vue3": "cross-env FRAMEWORK=vue3 playwright test", + "test:angular": "cross-env FRAMEWORK=angular playwright test" }, "dependencies": { "@angular/common": "catalog:angular", @@ -59,11 +59,13 @@ "@angular/cli": "catalog:angular", "@angular/compiler-cli": "catalog:angular", "@eslint/js": "catalog:", + "@playwright/test": "catalog:", "@types/jasmine": "5.1.4", "@types/react": "19.1.2", "@types/react-dom": "19.1.3", "@vitejs/plugin-react": "4.7.0", "@vitejs/plugin-vue": "5.2.4", + "cross-env": "7.0.3", "eslint": "9.39.4", "eslint-plugin-react-hooks": "7.0.1", "eslint-plugin-react-refresh": "0.5.2", @@ -74,7 +76,6 @@ "karma-coverage": "2.2.1", "karma-jasmine": "5.1.0", "karma-jasmine-html-reporter": "2.1.0", - "testcafe": "catalog:", "typescript": "5.8.3", "vite": "8.0.16" }, diff --git a/e2e/wrappers/playwright.config.ts b/e2e/wrappers/playwright.config.ts new file mode 100644 index 000000000000..4acd82a80950 --- /dev/null +++ b/e2e/wrappers/playwright.config.ts @@ -0,0 +1,51 @@ +import { defineConfig } from '@playwright/test'; + +import type { Framework, TestOptions } from './fixtures'; + +const FRAMEWORK_PORTS: Record = { + react19: 3030, + angular: 3031, + vue3: 3032, +}; + +const framework = (process.env.FRAMEWORK ?? 'react19') as Framework; +const port = FRAMEWORK_PORTS[framework]; + +if(!port) { + throw new Error(`Unsupported framework: ${framework}. Expected one of: ${Object.keys(FRAMEWORK_PORTS).join(', ')}.`); +} + +const baseURL = `http://localhost:${port}`; + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + workers: 1, + reporter: process.env.CI + ? [['list'], ['html', { open: 'never' }]] + : [['list']], + timeout: 30000, + expect: { timeout: 5000 }, + use: { + framework, + baseURL, + channel: 'chrome', + headless: true, + viewport: { width: 1200, height: 800 }, + launchOptions: { + args: ['--no-sandbox', '--disable-gpu'], + // Playwright hides scrollbars in headless by default, so the page gets no scrollbar + // gutter and its layout differs from a real browser and from the TestCafe etalons. + ignoreDefaultArgs: ['--hide-scrollbars'], + }, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + webServer: { + command: `node ./serve.js --framework=${framework} --port=${port}`, + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 30000, + }, +}); diff --git a/e2e/wrappers/runner.js b/e2e/wrappers/runner.js deleted file mode 100644 index 13e6d6cec50f..000000000000 --- a/e2e/wrappers/runner.js +++ /dev/null @@ -1,106 +0,0 @@ -const path = require('path'); -const process = require('process'); -const minimist = require('minimist'); -const express = require('express'); -const createTestCafe = require('testcafe'); - -const argv = minimist(process.argv.slice(2)); -const framework = argv.framework || 'react'; - -const http = require('http'); - -function waitForServerReady(port, timeout = 5000) { - const deadline = Date.now() + timeout; - - return new Promise((resolve, reject) => { - const check = () => { - http.get(`http://localhost:${port}`, res => { - res.destroy(); - resolve(); - }).on('error', () => { - if (Date.now() > deadline) { - reject(new Error(`Timeout waiting for http://localhost:${port}`)); - } else { - setTimeout(check, 100); - } - }); - }; - - check(); - }); -} - -const frameworkConfig = { - react: { - staticPath: path.resolve(__dirname, 'builders/react19/dist/index.html'), - port: 3030, - }, - angular: { - staticPath: path.resolve(__dirname, 'builders/angular/dist/angular/browser/index.html'), - port: 3031, - }, - vue: { - staticPath: path.resolve(__dirname, 'builders/vue3/dist/index.html'), - port: 3032, - }, -}; - -if (!frameworkConfig[framework]) { - console.error(`❌ Unsupported framework: ${framework}`); - process.exit(1); -} - -const { staticPath, port } = frameworkConfig[framework]; -process.env.FRAMEWORK = framework; -process.env.E2E_TEST_PORT = port; - -const startStaticServer = () => - new Promise((resolve, reject) => { - const app = express(); - app.use(express.static(path.dirname(staticPath))); - app.get('*', (_, res) => res.sendFile(staticPath)); - - const server = app.listen(port, () => { - console.log(`✅ Server for ${framework} running at http://localhost:${port}`); - resolve(server); - }); - - server.on('error', reject); - }); - -(async () => { - let testcafe; - let server; - - try { - server = await startStaticServer(); - - await waitForServerReady(port, 5000); - - testcafe = await createTestCafe(); - - const runner = testcafe.createRunner(); - - const failedCount = await runner - .src('./tests/**/*.js') - .browsers(process.env.BROWSER || 'chrome:headless --no-sandbox --disable-gpu --window-size=1200,800') - .concurrency(1) - .run({ - skipJsErrors: true, - selectorTimeout: 3000, - assertionTimeout: 1000, - pageLoadTimeout: 5000, - }); - - console.log(`✅ E2E complete. Failed tests: ${failedCount}`); - process.exit(failedCount); - - } catch (err) { - console.error('❌ E2E test run failed:', err); - process.exit(1); - - } finally { - if (testcafe) await testcafe.close(); - if (server && server.close) await new Promise((res) => server.close(res)); - } -})(); diff --git a/e2e/wrappers/serve.js b/e2e/wrappers/serve.js new file mode 100644 index 000000000000..43caca16ee89 --- /dev/null +++ b/e2e/wrappers/serve.js @@ -0,0 +1,43 @@ +const fs = require('fs'); +const path = require('path'); +const process = require('process'); +const minimist = require('minimist'); +const express = require('express'); + +const DIST_DIRS = { + react19: 'builders/react19/dist', + angular: 'builders/angular/dist/angular/browser', + vue3: 'builders/vue3/dist', +}; + +const argv = minimist(process.argv.slice(2)); +const framework = argv.framework; +const port = Number(argv.port); +const distDir = DIST_DIRS[framework]; + +if(!distDir) { + console.error(`❌ Unsupported framework: ${framework}. Expected one of: ${Object.keys(DIST_DIRS).join(', ')}.`); + process.exit(1); +} + +if(!Number.isInteger(port) || port <= 0) { + console.error(`❌ Invalid port: ${argv.port}. Pass a positive integer, for example --port=3030.`); + process.exit(1); +} + +const root = path.resolve(__dirname, distDir); +const indexPath = path.join(root, 'index.html'); + +if(!fs.existsSync(indexPath)) { + console.error(`❌ Build for ${framework} is not found at ${root}. Run "pnpm run build:${framework}" first.`); + process.exit(1); +} + +const app = express(); + +app.use(express.static(root)); +app.get('*', (_, res) => res.sendFile(indexPath)); + +app.listen(port, () => { + console.log(`✅ Server for ${framework} running at http://localhost:${port}`); +}); diff --git a/e2e/wrappers/test-helpers.js b/e2e/wrappers/test-helpers.js deleted file mode 100644 index be9fce8d132a..000000000000 --- a/e2e/wrappers/test-helpers.js +++ /dev/null @@ -1,22 +0,0 @@ -import { fixture, test } from 'testcafe'; -const process = require('process'); - -export function navigateToComponent(t, baseUrl, examplePath) { - return t.navigateTo(`${baseUrl}/examples/${examplePath}`); -} - -export function testInFramework(fixtureName, examplePath, ...testData) { - const frameworkName = process.env.FRAMEWORK || 'react'; - const port = process.env.E2E_TEST_PORT || 3030; - const baseUrl = `http://localhost:${port}`; - - fixture(fixtureName).page(baseUrl); - - for(let [testName, testFn] of testData) { - test(testName, async t => { - await navigateToComponent(t, baseUrl, examplePath); - - await testFn(t); - }); - } -} diff --git a/e2e/wrappers/tests/button.spec.ts b/e2e/wrappers/tests/button.spec.ts new file mode 100644 index 000000000000..a50dc9b01d58 --- /dev/null +++ b/e2e/wrappers/tests/button.spec.ts @@ -0,0 +1,11 @@ +import { expect, test } from '../fixtures'; + +test.describe('Button scenarios', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/examples/button'); + }); + + test('Button should exist', async ({ page }) => { + await expect(page.locator('.dx-button-text')).toBeAttached(); + }); +}); diff --git a/e2e/wrappers/tests/button.test.js b/e2e/wrappers/tests/button.test.js deleted file mode 100644 index 7966bf3799bc..000000000000 --- a/e2e/wrappers/tests/button.test.js +++ /dev/null @@ -1,11 +0,0 @@ -import { Selector } from 'testcafe'; -import { testInFramework } from '../test-helpers'; - -testInFramework('Button scenarios', 'button', [ - 'Button should exist', - async (t) => { - const button = Selector('.dx-button-text'); - - await t.expect(button.exists).ok('Button should exist'); - } -]); \ No newline at end of file diff --git a/e2e/wrappers/tests/chat-template-rerender.spec.ts b/e2e/wrappers/tests/chat-template-rerender.spec.ts new file mode 100644 index 000000000000..025341454d66 --- /dev/null +++ b/e2e/wrappers/tests/chat-template-rerender.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from '../fixtures'; + +test.describe('Chat template re-rendering', () => { + test.skip(({ framework }) => framework !== 'react19', 'The example is implemented for React only'); + + test.beforeEach(async ({ page }) => { + await page.goto('/examples/chat-template-rerender'); + }); + + test('Chat should be able to re-render its messages', async ({ page }) => { + const textarea = page.locator('.dx-chat-messagebox textarea'); + const sendButton = page.locator('.dx-chat-messagebox .dx-chat-textarea-toolbar .dx-button'); + const assistantBubble = page.locator('.chat-messagebubble-text').nth(1); + const regenerateButton = page.locator('.dx-icon-refresh').nth(1); + + await textarea.pressSequentially('Hi there!'); + await sendButton.click(); + + await expect(assistantBubble).toHaveText('How can I help you?'); + + await regenerateButton.click(); + + await expect(assistantBubble).toHaveText('In other words, what do you want?'); + }); +}); diff --git a/e2e/wrappers/tests/chat-template-rerender.test.js b/e2e/wrappers/tests/chat-template-rerender.test.js deleted file mode 100644 index aa327c8f73ce..000000000000 --- a/e2e/wrappers/tests/chat-template-rerender.test.js +++ /dev/null @@ -1,21 +0,0 @@ -import { Selector } from 'testcafe'; -import { testInFramework } from '../test-helpers'; - -if(process.env.FRAMEWORK === 'react') { - testInFramework('Chat template re-rendering', 'chat-template-rerender', [ - 'Chat should be able to re-render its messages', - async (t) => { - const textarea = Selector('.dx-chat-messagebox textarea'); - const sendButton = Selector('.dx-chat-messagebox .dx-chat-textarea-toolbar .dx-button'); - const assistantBubble = Selector('.chat-messagebubble-text').nth(1); - const regenerateButton = Selector('.dx-icon-refresh').nth(1); - - await t - .typeText(textarea, 'Hi there!') - .click(sendButton) - .expect(assistantBubble.textContent).eql('How can I help you?') - .click(regenerateButton) - .expect(assistantBubble.textContent).eql('In other words, what do you want?'); - } - ]); -} diff --git a/e2e/wrappers/tests/gantt-template-state-update.spec.ts b/e2e/wrappers/tests/gantt-template-state-update.spec.ts new file mode 100644 index 000000000000..cddc05d042b1 --- /dev/null +++ b/e2e/wrappers/tests/gantt-template-state-update.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from '../fixtures'; + +test.describe('Gantt template state update', () => { + test.skip(({ framework }) => framework !== 'react19', 'The example is implemented for React only'); + + test.beforeEach(async ({ page }) => { + await page.goto('/examples/gantt-template-state-update'); + }); + + test('Gantt should be able to unmount its template when a parent component\'s state update happens', async ({ page }) => { + const hideButton = page.locator('button', { hasText: 'Hide Data' }); + const noDataPlaceholder = page.locator('.dx-treelist-nodata'); + + await hideButton.click(); + + await expect(noDataPlaceholder).toBeVisible(); + await expect(noDataPlaceholder).toHaveText('No data'); + }); +}); diff --git a/e2e/wrappers/tests/gantt-template-state-update.test.js b/e2e/wrappers/tests/gantt-template-state-update.test.js deleted file mode 100644 index 7d402aaeab4b..000000000000 --- a/e2e/wrappers/tests/gantt-template-state-update.test.js +++ /dev/null @@ -1,17 +0,0 @@ -import { Selector } from 'testcafe'; -import { testInFramework } from '../test-helpers'; - -if(process.env.FRAMEWORK === 'react') { - testInFramework('Gantt template state update', 'gantt-template-state-update', [ - 'Gantt should be able to ummount its template when a parent component\'s state update happens', - async (t) => { - const hideButton = Selector('button').withText('Hide Data'); - const noDataPlaceholder = Selector('.dx-treelist-nodata'); - - await t - .click(hideButton) - .expect(noDataPlaceholder.visible).ok() - .expect(noDataPlaceholder.textContent).eql('No data'); - } - ]); -} diff --git a/e2e/wrappers/tests/inputs-list-in-form.spec.ts b/e2e/wrappers/tests/inputs-list-in-form.spec.ts new file mode 100644 index 000000000000..71f6a1e6cf7e --- /dev/null +++ b/e2e/wrappers/tests/inputs-list-in-form.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from '../fixtures'; + +test.describe('inputs-list-in-form scenarios', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/examples/inputs-list-in-form'); + }); + + test('Phone inputs should be added and deleted correctly', async ({ page }) => { + const addButton = page.locator('.dx-button-text', { hasText: 'Add phone' }); + const deleteButton = page.locator('.dx-button', { has: page.locator('.dx-icon-trash') }); + const phoneGroup = page.locator('[aria-labelledby$="_phones-container"]'); + const phoneInputs = phoneGroup.locator('.dx-texteditor-container'); + + await expect(phoneInputs).toHaveCount(0); + + await addButton.click(); + await expect(phoneInputs).toHaveCount(1); + + await deleteButton.click(); + await expect(phoneInputs).toHaveCount(0); + }); +}); diff --git a/e2e/wrappers/tests/inputs-list-in-form.test.js b/e2e/wrappers/tests/inputs-list-in-form.test.js deleted file mode 100644 index ae61e713564a..000000000000 --- a/e2e/wrappers/tests/inputs-list-in-form.test.js +++ /dev/null @@ -1,22 +0,0 @@ -import { Selector } from 'testcafe'; -import { testInFramework } from '../test-helpers'; - -testInFramework('inputs-list-in-form scenarios', 'inputs-list-in-form', [ - 'Phone inputs should adding and deleting correctly', - async (t) => { - const addButton = Selector('.dx-button-text').withText('Add phone'); - const deleteButton = Selector('.dx-icon-trash').parent('.dx-button'); - - const phoneGroup = Selector('[aria-labelledby$="_phones-container"]'); - - const phoneInputs = phoneGroup.find('.dx-texteditor-container'); - - await t.expect(phoneInputs.count).eql(0, 'Phone inputs should not exist initially'); - - await t.click(addButton); - await t.expect(phoneInputs.count).eql(1, 'There should be exactly one phone input after adding'); - - await t.click(deleteButton); - await t.expect(phoneInputs.count).eql(0, 'Phone inputs should be removed after deletion'); - } -]); diff --git a/e2e/wrappers/tests/select-box-nested-validator.spec.ts b/e2e/wrappers/tests/select-box-nested-validator.spec.ts new file mode 100644 index 000000000000..df26be374309 --- /dev/null +++ b/e2e/wrappers/tests/select-box-nested-validator.spec.ts @@ -0,0 +1,39 @@ +import { expect, test } from '../fixtures'; + +test.describe('SelectBox nested validator scenarios', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/examples/select-box-nested-validator'); + }); + + test('SelectBox with nested Validator component should not render double errors', async ({ page }) => { + const validateButton = page.locator('.dx-button-text', { hasText: 'Validate' }); + const validationSummary = page.locator('.dx-validationsummary'); + + await expect(validateButton).toBeAttached(); + await expect(validationSummary).toBeAttached(); + + await validateButton.click(); + + const validationSummaryItems = validationSummary.locator('.dx-validationsummary-item'); + + await expect(validationSummaryItems).toHaveCount(1); + await expect(validationSummaryItems.first()).toHaveText('Type is required'); + }); + + test('SelectBox validation should pass when value is selected', async ({ page }) => { + const validateButton = page.locator('.dx-button-text', { hasText: 'Validate' }); + const validationSummary = page.locator('.dx-validationsummary'); + const selectBoxArrow = page.locator('.dx-selectbox .dx-dropdowneditor-button'); + + await selectBoxArrow.click(); + + const firstItem = page.locator('.dx-item', { hasText: 'One' }); + + await expect(firstItem).toBeVisible(); + await firstItem.click(); + + await validateButton.click(); + + await expect(validationSummary.locator('.dx-validationsummary-item')).toHaveCount(0); + }); +}); diff --git a/e2e/wrappers/tests/select-box-nested-validator.test.js b/e2e/wrappers/tests/select-box-nested-validator.test.js deleted file mode 100644 index 3a7f7f83f328..000000000000 --- a/e2e/wrappers/tests/select-box-nested-validator.test.js +++ /dev/null @@ -1,41 +0,0 @@ -import { Selector } from 'testcafe'; -import { testInFramework } from '../test-helpers'; - -testInFramework('SelectBox nested validator scenarios', 'select-box-nested-validator', [ - 'SelectBox with nested Validator component should not render double errors', - async (t) => { - const validateButton = Selector('.dx-button-text').withText('Validate'); - const validationSummary = Selector('.dx-validationsummary'); - - await t - .expect(validateButton.exists).ok('Validate button should exist') - .expect(validationSummary.exists).ok('Validation summary should exist'); - - await t.click(validateButton); - - const updatedValidationSummaryItems = validationSummary.find('.dx-validationsummary-item'); - await t.expect(updatedValidationSummaryItems.count).eql(1, 'Should have exactly one validation error in summary'); - const errorMessage = await updatedValidationSummaryItems.nth(0).innerText; - await t.expect(errorMessage).eql('Type is required', 'Error message should be "Type is required"'); - }, - - 'SelectBox validation should pass when value is selected', - async (t) => { - const validateButton = Selector('.dx-button-text').withText('Validate'); - const validationSummary = Selector('.dx-validationsummary'); - const selectBox = Selector('.dx-selectbox'); - const selectBoxArrow = selectBox.find('.dx-dropdowneditor-button'); - - await t.click(selectBoxArrow); - const firstItem = Selector('.dx-item').withText('One'); - - await t - .expect(firstItem.exists).ok('First item should exist in dropdown') - .click(firstItem); - - await t.click(validateButton); - - const updatedValidationSummaryItems = validationSummary.find('.dx-validationsummary-item'); - await t.expect(updatedValidationSummaryItems.count).eql(0, 'Should have no validation errors when value is selected'); - } -]); diff --git a/e2e/wrappers/tests/textbox.spec.ts b/e2e/wrappers/tests/textbox.spec.ts new file mode 100644 index 000000000000..26c84d61c172 --- /dev/null +++ b/e2e/wrappers/tests/textbox.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from '../fixtures'; + +test.describe('TextBox Dynamic Styles scenarios', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/examples/text-box-dynamic-styles'); + }); + + test('TextBox should update inline styles', async ({ page }) => { + const textboxWrapper = page.locator('.dx-textbox'); + const textboxInput = page.locator('.dx-texteditor-input'); + + await expect(textboxWrapper).toBeAttached(); + await expect(textboxInput).toBeAttached(); + + await textboxInput.pressSequentially('trigger'); + await textboxInput.press('Enter'); + + await expect(textboxInput).toHaveValue('trigger'); + await expect(textboxWrapper).toHaveCSS('background-color', 'rgb(255, 99, 132)'); + + await textboxInput.pressSequentially(' again'); + await textboxInput.press('Enter'); + + await expect(textboxInput).toHaveValue('trigger again'); + await expect(textboxWrapper).toHaveCSS('background-color', 'rgb(54, 162, 235)'); + }); +}); diff --git a/e2e/wrappers/tests/textbox.test.js b/e2e/wrappers/tests/textbox.test.js deleted file mode 100644 index 5dc2fcbdedfe..000000000000 --- a/e2e/wrappers/tests/textbox.test.js +++ /dev/null @@ -1,25 +0,0 @@ -import { Selector } from 'testcafe'; -import { testInFramework } from '../test-helpers'; - -testInFramework('TextBox Dynamic Styles scenarios', 'text-box-dynamic-styles', [ - 'TextBox should update inline styles', - async (t) => { - const textboxWrapper = Selector('.dx-textbox'); - const textboxInput = Selector('.dx-texteditor-input'); - await t.expect(textboxWrapper.exists).ok('TextBox Wrapper should exist'); - await t.expect(textboxInput.exists).ok('TextBox Input should exist'); - await t - .typeText(textboxInput, 'trigger') - .pressKey('enter') - .expect(textboxInput.value) - .eql('trigger') - .expect(textboxWrapper.getStyleProperty('background-color')) - .eql('rgb(255, 99, 132)') - .typeText(textboxInput, ' again') - .pressKey('enter') - .expect(textboxInput.value) - .eql('trigger again') - .expect(textboxWrapper.getStyleProperty('background-color')) - .eql('rgb(54, 162, 235)'); - }, -]); \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20a4ed193927..ea97abe27cf3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,6 +67,9 @@ catalogs: '@eslint/js': specifier: 9.39.4 version: 9.39.4 + '@playwright/test': + specifier: 1.62.1 + version: 1.62.1 '@stylistic/eslint-plugin': specifier: 5.10.0 version: 5.10.0 @@ -1306,6 +1309,9 @@ importers: '@eslint/js': specifier: 'catalog:' version: 9.39.4 + '@playwright/test': + specifier: 'catalog:' + version: 1.62.1 '@types/jasmine': specifier: 5.1.4 version: 5.1.4 @@ -1321,6 +1327,9 @@ importers: '@vitejs/plugin-vue': specifier: 5.2.4 version: 5.2.4(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.8.0)(sass-embedded@1.93.3)(sass@1.101.0)(terser@5.49.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.8.3)) + cross-env: + specifier: 7.0.3 + version: 7.0.3 eslint: specifier: 9.39.4 version: 9.39.4(jiti@2.6.1) @@ -1351,9 +1360,6 @@ importers: karma-jasmine-html-reporter: specifier: 2.1.0 version: 2.1.0(jasmine-core@5.6.0)(karma-jasmine@5.1.0(karma@6.4.4))(karma@6.4.4) - testcafe: - specifier: 'catalog:' - version: 3.7.5(supports-color@7.2.0) typescript: specifier: 5.8.3 version: 5.8.3 @@ -6594,6 +6600,11 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -11364,6 +11375,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + 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} @@ -14525,6 +14541,16 @@ packages: resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} engines: {node: '>=16.0.0'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + plimit-lit@1.6.1: resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==} engines: {node: '>=12'} @@ -23979,6 +24005,10 @@ snapshots: '@pkgr/core@0.3.6': {} + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + '@popperjs/core@2.11.8': {} '@preact/signals-core@1.14.1': {} @@ -30492,6 +30522,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -35499,6 +35532,14 @@ snapshots: pvutils: 1.1.5 tslib: 2.8.1 + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + plimit-lit@1.6.1: dependencies: queue-lit: 1.5.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3f0dc5c26848..253655cc38ab 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -91,6 +91,7 @@ catalog: zod: 3.24.4 zod-to-json-schema: 3.24.6 "@babel/eslint-parser": 7.29.7 + "@playwright/test": 1.62.1 "@eslint-stylistic/metadata": ^2.13.0 "@eslint/eslintrc": 3.3.5 "@stylistic/eslint-plugin": 5.10.0