From 239bb46b7198be9568d53f0528c3cc719f6fe369 Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Mon, 27 Jul 2026 17:02:06 +0200 Subject: [PATCH 01/30] QUnit-ESM: use esm bundling in testing for button group, for check approach --- packages/devextreme/project.json | 65 ++++++++++- .../esm-shims/fluent_blue_light.css.js | 3 + .../testing/helpers/esm-shims/gantt.css.js | 3 + .../helpers/esm-shims/generic_light.css.js | 3 + .../helpers/esm-shims/injectStylesheet.js | 16 +++ .../testing/helpers/esm-shims/jquery.js | 12 ++ .../esm-shims/material_blue_light.css.js | 3 + .../helpers/esm-shims/zod-to-json-schema.js | 8 ++ .../testing/helpers/esm-shims/zod.js | 35 ++++++ packages/devextreme/testing/runner/index.ts | 2 + .../testing/runner/lib/importMap.ts | 85 ++++++++++++++ .../devextreme/testing/runner/lib/pages.ts | 60 ++++++++-- .../devextreme/testing/runner/lib/static.ts | 64 ++++++++++- .../devextreme/testing/runner/lib/types.ts | 2 + .../runner/templates/run-suite.template.html | 104 +++++++++++++----- 15 files changed, 417 insertions(+), 48 deletions(-) create mode 100644 packages/devextreme/testing/helpers/esm-shims/fluent_blue_light.css.js create mode 100644 packages/devextreme/testing/helpers/esm-shims/gantt.css.js create mode 100644 packages/devextreme/testing/helpers/esm-shims/generic_light.css.js create mode 100644 packages/devextreme/testing/helpers/esm-shims/injectStylesheet.js create mode 100644 packages/devextreme/testing/helpers/esm-shims/jquery.js create mode 100644 packages/devextreme/testing/helpers/esm-shims/material_blue_light.css.js create mode 100644 packages/devextreme/testing/helpers/esm-shims/zod-to-json-schema.js create mode 100644 packages/devextreme/testing/helpers/esm-shims/zod.js create mode 100644 packages/devextreme/testing/runner/lib/importMap.ts diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index e1f741f3fce4..1f70574ae496 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -417,6 +417,62 @@ "{projectRoot}/artifacts/transpiled-esm-npm/esm/__internal" ] }, + "build:npm:esm:watch": { + "executor": "devextreme-nx-infra-plugin:babel-transform", + "cache": false, + "options": { + "babelConfigPath": "./build/transpile-config.js", + "configKey": "esm", + "sourcePattern": "./js/**/*.{js,jsx}", + "excludePatterns": [ + "./js/**/*.d.ts", + "./js/__internal/**/*" + ], + "outDir": "./artifacts/transpiled-esm-npm/esm", + "removeDebug": true, + "watch": true, + "copyAssets": [ + { "from": "./js/localization/messages", "to": "./localization/messages" }, + { "from": "./js/viz/vector_map.utils/_settings.json", "to": "./viz/vector_map.utils/_settings.json" } + ] + } + }, + "build:npm:esm:internal:watch": { + "executor": "devextreme-nx-infra-plugin:babel-transform", + "cache": false, + "options": { + "babelConfigPath": "./build/transpile-config.js", + "configKey": "esm", + "sourcePattern": "./artifacts/dist_ts/__internal/**/*.{js,jsx}", + "outDir": "./artifacts/transpiled-esm-npm/esm/__internal", + "removeDebug": true, + "watch": true, + "renameExtensions": { + ".jsx": ".js" + } + } + }, + "build:qunit-esm": { + "executor": "nx:run-commands", + "options": { + "cwd": "{projectRoot}", + "parallel": true, + "commands": [ + "pnpm nx run devextreme:build:npm:esm", + "pnpm nx run devextreme:build:npm:esm:internal" + ] + }, + "dependsOn": [ + "build:ts:internal" + ], + "outputs": [ + "{projectRoot}/artifacts/transpiled-esm-npm/esm" + ], + "cache": true, + "metadata": { + "description": "ESM artifacts for QUnit native import-map loader (?loader=esm)." + } + }, "build:npm:cjs:internal": { "executor": "devextreme-nx-infra-plugin:babel-transform", "options": { @@ -532,11 +588,12 @@ "pnpm nx build:ts:internal devextreme", "pnpm nx run-many --targets=build:cjs,build:cjs:internal,build:cjs:bundles --projects=devextreme --parallel", "pnpm nx run-many --targets=build:cjs,build:cjs:internal,build:cjs:bundles --projects=devextreme --parallel -c production", - "pnpm nx run-many --targets=build:npm:cjs,build:npm:cjs:internal --projects=devextreme --parallel", + "pnpm nx run-many --targets=build:npm:esm,build:npm:esm:internal,build:npm:cjs,build:npm:cjs:internal --projects=devextreme --parallel", "pnpm nx clean:dist-ts devextreme" ], "outputs": [ "{projectRoot}/artifacts/transpiled", + "{projectRoot}/artifacts/transpiled-esm-npm", "{projectRoot}/artifacts/transpiled-renovation-npm", "{projectRoot}/build/bundle-templates/dx.custom.js", "{projectRoot}/artifacts/npm/devextreme/bundles/dx.custom.config.js" @@ -573,7 +630,9 @@ "pnpm nx build:cjs:watch devextreme", "pnpm nx build:cjs:watch devextreme -c production", "pnpm nx build:cjs:internal:watch devextreme", - "pnpm nx build:cjs:internal:watch devextreme -c production" + "pnpm nx build:cjs:internal:watch devextreme -c production", + "pnpm nx build:npm:esm:watch devextreme", + "pnpm nx build:npm:esm:internal:watch devextreme" ], "cwd": "{projectRoot}", "parallel": true @@ -1755,7 +1814,7 @@ ], "cache": true, "metadata": { - "description": "Dev/CI test build. Skips prod bundles, aspnet, declarations, npm, and license checks." + "description": "Dev/CI test build. Includes CJS + ESM (QUnit import-map) artifacts; skips prod bundles, aspnet, declarations, npm package packing, and license checks." } }, "build:systemjs": { diff --git a/packages/devextreme/testing/helpers/esm-shims/fluent_blue_light.css.js b/packages/devextreme/testing/helpers/esm-shims/fluent_blue_light.css.js new file mode 100644 index 000000000000..49b5d883a67d --- /dev/null +++ b/packages/devextreme/testing/helpers/esm-shims/fluent_blue_light.css.js @@ -0,0 +1,3 @@ +import { injectStylesheet } from './injectStylesheet.js'; + +injectStylesheet('/packages/devextreme/artifacts/css/dx.fluent.blue.light.css'); diff --git a/packages/devextreme/testing/helpers/esm-shims/gantt.css.js b/packages/devextreme/testing/helpers/esm-shims/gantt.css.js new file mode 100644 index 000000000000..06d0a6d2f205 --- /dev/null +++ b/packages/devextreme/testing/helpers/esm-shims/gantt.css.js @@ -0,0 +1,3 @@ +import { injectStylesheet } from './injectStylesheet.js'; + +injectStylesheet('/packages/devextreme/artifacts/css/dx-gantt.css'); diff --git a/packages/devextreme/testing/helpers/esm-shims/generic_light.css.js b/packages/devextreme/testing/helpers/esm-shims/generic_light.css.js new file mode 100644 index 000000000000..727c7b2d4458 --- /dev/null +++ b/packages/devextreme/testing/helpers/esm-shims/generic_light.css.js @@ -0,0 +1,3 @@ +import { injectStylesheet } from './injectStylesheet.js'; + +injectStylesheet('/packages/devextreme/artifacts/css/dx.light.css'); diff --git a/packages/devextreme/testing/helpers/esm-shims/injectStylesheet.js b/packages/devextreme/testing/helpers/esm-shims/injectStylesheet.js new file mode 100644 index 000000000000..0343e793af9a --- /dev/null +++ b/packages/devextreme/testing/helpers/esm-shims/injectStylesheet.js @@ -0,0 +1,16 @@ +/** + * Injects a stylesheet once for ESM import-map QUnit mode + * (replaces SystemJS `*.css!` plugin imports). + */ +export function injectStylesheet(href) { + const existing = document.querySelector(`link[data-dx-esm-css="${href}"]`); + if(existing) { + return; + } + + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = href; + link.setAttribute('data-dx-esm-css', href); + document.head.appendChild(link); +} diff --git a/packages/devextreme/testing/helpers/esm-shims/jquery.js b/packages/devextreme/testing/helpers/esm-shims/jquery.js new file mode 100644 index 000000000000..48e9608cb077 --- /dev/null +++ b/packages/devextreme/testing/helpers/esm-shims/jquery.js @@ -0,0 +1,12 @@ +/** + * ESM jquery shim for QUnit import-map loader. + * jQuery is loaded via classic script tag before modules run. + */ +const $ = window.jQuery; + +if(!$ || typeof $.fn === 'undefined') { + throw new Error('ESM jquery shim: window.jQuery is not available'); +} + +export default $; +export { $ }; diff --git a/packages/devextreme/testing/helpers/esm-shims/material_blue_light.css.js b/packages/devextreme/testing/helpers/esm-shims/material_blue_light.css.js new file mode 100644 index 000000000000..d29a4ad74bee --- /dev/null +++ b/packages/devextreme/testing/helpers/esm-shims/material_blue_light.css.js @@ -0,0 +1,3 @@ +import { injectStylesheet } from './injectStylesheet.js'; + +injectStylesheet('/packages/devextreme/artifacts/css/dx.material.blue.light.css'); diff --git a/packages/devextreme/testing/helpers/esm-shims/zod-to-json-schema.js b/packages/devextreme/testing/helpers/esm-shims/zod-to-json-schema.js new file mode 100644 index 000000000000..c1ce5b58b1a2 --- /dev/null +++ b/packages/devextreme/testing/helpers/esm-shims/zod-to-json-schema.js @@ -0,0 +1,8 @@ +/** + * Minimal zod-to-json-schema stub for QUnit ESM / import-map loader. + */ +export function zodToJsonSchema() { + return { type: 'object' }; +} + +export default zodToJsonSchema; diff --git a/packages/devextreme/testing/helpers/esm-shims/zod.js b/packages/devextreme/testing/helpers/esm-shims/zod.js new file mode 100644 index 000000000000..489d0bbcf5ba --- /dev/null +++ b/packages/devextreme/testing/helpers/esm-shims/zod.js @@ -0,0 +1,35 @@ +/** + * Minimal zod stub for QUnit ESM / import-map loader. + */ +const z = { + object() { return z; }, + string() { return z; }, + boolean() { return z; }, + number() { return z; }, + date() { return z; }, + null() { return z; }, + enum() { return z; }, + union() { return z; }, + array() { return z; }, + tuple() { return z; }, + literal() { return z; }, + record() { return z; }, + lazy() { return z; }, + optional() { return z; }, + nullable() { return z; }, + // eslint-disable-next-line spellcheck/spell-checker + nullish() { return z; }, + strict() { return z; }, + int() { return z; }, + // eslint-disable-next-line spellcheck/spell-checker + nonnegative() { return z; }, + positive() { return z; }, + min() { return z; }, + max() { return z; }, + transform() { return z; }, + describe() { return z; }, + safeParse() { return { success: true, data: {} }; }, +}; + +export { z }; +export default z; diff --git a/packages/devextreme/testing/runner/index.ts b/packages/devextreme/testing/runner/index.ts index 81dd39cde514..76f40dd2b2e2 100644 --- a/packages/devextreme/testing/runner/index.ts +++ b/packages/devextreme/testing/runner/index.ts @@ -82,6 +82,7 @@ const pages = createPagesRenderer({ contentWithCacheBuster, getCacheBuster, jsonString, + packageRoot: PACKAGE_ROOT, renderTemplate: templates.renderTemplate, }); const suitesService = createSuitesService({ @@ -377,6 +378,7 @@ function assignBaseRunProps(searchParams: URLSearchParams): BaseRunProps { ShadowDom: searchParams.has('shadowDom'), WorkerInWindow: searchParams.has('workerinwindow'), NoCsp: searchParams.has('nocsp'), + UseEsm: searchParams.get('loader') === 'esm', MaxWorkers: null, }; diff --git a/packages/devextreme/testing/runner/lib/importMap.ts b/packages/devextreme/testing/runner/lib/importMap.ts new file mode 100644 index 000000000000..ea44afb07e5b --- /dev/null +++ b/packages/devextreme/testing/runner/lib/importMap.ts @@ -0,0 +1,85 @@ +const ESM_ROOT = '/packages/devextreme/artifacts/transpiled-esm-npm/esm'; +const SHIMS = '/packages/devextreme/testing/helpers/esm-shims'; +const NODE_MODULES = '/packages/devextreme/node_modules'; + +export interface ImportMapOptions { + jqueryUrl: string; + cacheBuster: string; +} + +export interface BrowserImportMap { + imports: Record; +} + +function withCacheBuster(url: string, cacheBuster: string): string { + if (!cacheBuster) { + return url; + } + + return `${url}${url.includes('?') ? '&' : '?'}${cacheBuster}`; +} + +/** + * Builds a browser import map for QUnit ESM pilot loader. + * Bare prefixes mirror SystemJS map + Vite playground aliases. + */ +export function buildQunitImportMap({ + cacheBuster, +}: ImportMapOptions): BrowserImportMap { + const rawImports: Record = { + // Package roots (trailing slash = prefix remap) + 'ui/': `${ESM_ROOT}/ui/`, + 'core/': `${ESM_ROOT}/core/`, + 'common/': `${ESM_ROOT}/common/`, + 'data/': `${ESM_ROOT}/data/`, + 'events/': `${ESM_ROOT}/events/`, + 'animation/': `${ESM_ROOT}/animation/`, + 'localization/': `${ESM_ROOT}/localization/`, + 'file_management/': `${ESM_ROOT}/file_management/`, + 'integration/': `${ESM_ROOT}/integration/`, + 'viz/': `${ESM_ROOT}/viz/`, + '__internal/': `${ESM_ROOT}/__internal/`, + 'renovation/': `${ESM_ROOT}/renovation/`, + + // Exact package entry points used as bare imports + localization: `${ESM_ROOT}/localization.js`, + events: `${ESM_ROOT}/events.js`, + data: `${ESM_ROOT}/data.js`, + animation: `${ESM_ROOT}/animation.js`, + + jquery: `${SHIMS}/jquery.js`, + + // Injected by babel transform-runtime (esm transpile) + '@babel/runtime/': `${NODE_MODULES}/@babel/runtime/`, + + // Vendors (prefer ESM builds where available) + inferno: `${NODE_MODULES}/inferno/dist/index.dev.esm.js`, + 'inferno-hydrate': `${NODE_MODULES}/inferno-hydrate/dist/index.dev.esm.js`, + 'inferno-create-element': `${NODE_MODULES}/inferno-create-element/dist/index.dev.esm.js`, + '@preact/signals-core': `${NODE_MODULES}/@preact/signals-core/dist/signals-core.module.js`, + + // eslint-disable-next-line spellcheck/spell-checker + fflate: `${NODE_MODULES}/fflate/esm/browser.js`, + + // SystemJS css! plugin replacements + 'fluent_blue_light.css!': `${SHIMS}/fluent_blue_light.css.js`, + 'generic_light.css!': `${SHIMS}/generic_light.css.js`, + 'material_blue_light.css!': `${SHIMS}/material_blue_light.css.js`, + 'gantt.css!': `${SHIMS}/gantt.css.js`, + + // Stubs + zod: `${SHIMS}/zod.js`, + 'zod-to-json-schema': `${SHIMS}/zod-to-json-schema.js`, + }; + + const imports: Record = {}; + Object.entries(rawImports).forEach(([key, url]) => { + imports[key] = withCacheBuster(url, cacheBuster); + }); + + return { imports }; +} + +export function getEsmModuleRoot(): string { + return ESM_ROOT; +} diff --git a/packages/devextreme/testing/runner/lib/pages.ts b/packages/devextreme/testing/runner/lib/pages.ts index ebcca6045c88..cea6cd6b3a35 100644 --- a/packages/devextreme/testing/runner/lib/pages.ts +++ b/packages/devextreme/testing/runner/lib/pages.ts @@ -1,6 +1,10 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + import { BaseRunProps, RunAllModel, RunSuiteModel, TemplateVars, } from './types'; +import { buildQunitImportMap, getEsmModuleRoot } from './importMap'; interface SystemPackage { main?: string; @@ -11,6 +15,7 @@ interface PagesRendererDeps { contentWithCacheBuster: (contentPath: string, cacheBuster: string) => string; getCacheBuster: (searchParams: URLSearchParams) => string; jsonString: (value: unknown) => string; + packageRoot: string; renderTemplate: (templateName: string, vars?: TemplateVars) => string; } @@ -28,6 +33,7 @@ export function createPagesRenderer({ contentWithCacheBuster, getCacheBuster, jsonString, + packageRoot, renderTemplate, }: PagesRendererDeps): PagesRenderer { function renderIndexPage(): string { @@ -70,10 +76,24 @@ export function createPagesRenderer({ const isSelfSufficientTest = scriptVirtualPath.includes('_bundled') || scriptVirtualPath.includes('Bundles') || scriptVirtualPath.includes('DevExpress.jquery'); + const useEsm = runProps.UseEsm; + const esmRootPath = path.join(packageRoot, 'artifacts', 'transpiled-esm-npm', 'esm'); + const esmReady = fs.existsSync(path.join(esmRootPath, 'integration', 'jquery.js')); + + if (useEsm && !esmReady) { + return `ESM artifacts missing +

QUnit ESM loader: artifacts missing

+

artifacts/transpiled-esm-npm/esm is not built.

+

pnpm run dev / build:dev should build ESM via build:transpile -c ci.

+

If artifacts were cleaned manually, rebuild:

+
pnpm nx run devextreme:build:qunit-esm
+

Then reload ?loader=esm.

+`; + } - const cspPart = runProps.NoCsp ? '' : '-systemjs'; - const npmModule = `transpiled${cspPart}`; - const testingBasePath = runProps.NoCsp + const cspPart = runProps.NoCsp || useEsm ? '' : '-systemjs'; + const npmModule = useEsm ? 'transpiled-esm-npm/esm' : `transpiled${cspPart}`; + const testingBasePath = runProps.NoCsp || useEsm ? '/packages/devextreme/testing/' : '/packages/devextreme/artifacts/transpiled-testing/'; @@ -86,7 +106,7 @@ export function createPagesRenderer({ } function getTestUrl(): string { - if (runProps.NoCsp) { + if (runProps.NoCsp || useEsm) { return scriptVirtualPath; } @@ -121,12 +141,25 @@ export function createPagesRenderer({ const qunitExtensionsJs = contentWithCacheBuster('/packages/devextreme/testing/helpers/qunitExtensions.js', cacheBuster); const jqueryJs = contentWithCacheBuster('/packages/devextreme/node_modules/jquery/dist/jquery.js', cacheBuster); const sinonJs = contentWithCacheBuster('/packages/devextreme/node_modules/sinon/pkg/sinon.js', cacheBuster); - const systemJs = contentWithCacheBuster( - runProps.NoCsp - ? '/packages/devextreme/node_modules/systemjs/dist/system.js' - : '/packages/devextreme/node_modules/systemjs/dist/system-csp-production.js', - cacheBuster, - ); + const systemJsScript = useEsm + ? '' + : ``; + + const importMap = useEsm + ? buildQunitImportMap({ + jqueryUrl: getJQueryUrl(), + cacheBuster, + }) + : null; + + const importMapScript = useEsm + ? `` + : ''; const cspMap: Record = !runProps.NoCsp ? { @@ -237,7 +270,7 @@ export function createPagesRenderer({ }; const integrationImportPaths = getJQueryIntegrationImports(); - const cspMetaTag = runProps.NoCsp + const cspMetaTag = runProps.NoCsp || useEsm ? '' : ` - + {{{SYSTEM_JS_SCRIPT}}} + + {{{IMPORT_MAP_SCRIPT}}}
@@ -131,34 +146,65 @@ From 76319f05b23181913d612d3189e077c9bd11a560 Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Mon, 27 Jul 2026 20:32:16 +0200 Subject: [PATCH 02/30] QUnit-ESM: use esm bundling in testing by default --- .../esm-shims/fluent_blue_light.css.js | 2 +- .../testing/helpers/esm-shims/gantt.css.js | 2 +- .../helpers/esm-shims/generic_light.css.js | 2 +- .../helpers/esm-shims/injectStylesheet.js | 32 ++++++++++++- .../testing/helpers/esm-shims/localization.js | 17 +++++++ .../esm-shims/material_blue_light.css.js | 2 +- .../testing/helpers/keyboardMock.js | 22 ++++----- .../devextreme/testing/helpers/pointerMock.js | 24 ++++------ packages/devextreme/testing/runner/index.ts | 3 +- .../testing/runner/lib/importMap.ts | 46 ++++++++++++++++++- .../devextreme/testing/runner/lib/pages.ts | 6 ++- .../devextreme/testing/runner/lib/static.ts | 25 ++++++++++ .../devextreme/testing/runner/lib/types.ts | 2 +- .../runner/templates/run-suite.template.html | 6 +-- 14 files changed, 149 insertions(+), 42 deletions(-) create mode 100644 packages/devextreme/testing/helpers/esm-shims/localization.js diff --git a/packages/devextreme/testing/helpers/esm-shims/fluent_blue_light.css.js b/packages/devextreme/testing/helpers/esm-shims/fluent_blue_light.css.js index 49b5d883a67d..fa9bc508e273 100644 --- a/packages/devextreme/testing/helpers/esm-shims/fluent_blue_light.css.js +++ b/packages/devextreme/testing/helpers/esm-shims/fluent_blue_light.css.js @@ -1,3 +1,3 @@ import { injectStylesheet } from './injectStylesheet.js'; -injectStylesheet('/packages/devextreme/artifacts/css/dx.fluent.blue.light.css'); +await injectStylesheet('/packages/devextreme/artifacts/css/dx.fluent.blue.light.css'); diff --git a/packages/devextreme/testing/helpers/esm-shims/gantt.css.js b/packages/devextreme/testing/helpers/esm-shims/gantt.css.js index 06d0a6d2f205..e247d21d5527 100644 --- a/packages/devextreme/testing/helpers/esm-shims/gantt.css.js +++ b/packages/devextreme/testing/helpers/esm-shims/gantt.css.js @@ -1,3 +1,3 @@ import { injectStylesheet } from './injectStylesheet.js'; -injectStylesheet('/packages/devextreme/artifacts/css/dx-gantt.css'); +await injectStylesheet('/packages/devextreme/artifacts/css/dx-gantt.css'); diff --git a/packages/devextreme/testing/helpers/esm-shims/generic_light.css.js b/packages/devextreme/testing/helpers/esm-shims/generic_light.css.js index 727c7b2d4458..39269c34d8f9 100644 --- a/packages/devextreme/testing/helpers/esm-shims/generic_light.css.js +++ b/packages/devextreme/testing/helpers/esm-shims/generic_light.css.js @@ -1,3 +1,3 @@ import { injectStylesheet } from './injectStylesheet.js'; -injectStylesheet('/packages/devextreme/artifacts/css/dx.light.css'); +await injectStylesheet('/packages/devextreme/artifacts/css/dx.light.css'); diff --git a/packages/devextreme/testing/helpers/esm-shims/injectStylesheet.js b/packages/devextreme/testing/helpers/esm-shims/injectStylesheet.js index 0343e793af9a..f186c30c5454 100644 --- a/packages/devextreme/testing/helpers/esm-shims/injectStylesheet.js +++ b/packages/devextreme/testing/helpers/esm-shims/injectStylesheet.js @@ -1,11 +1,14 @@ /** * Injects a stylesheet once for ESM import-map QUnit mode * (replaces SystemJS `*.css!` plugin imports). + * + * Returns a Promise so importers can `await` load — otherwise tests that + * assert computed styles race the async fetch. */ export function injectStylesheet(href) { const existing = document.querySelector(`link[data-dx-esm-css="${href}"]`); if(existing) { - return; + return waitForStylesheet(existing, href); } const link = document.createElement('link'); @@ -13,4 +16,31 @@ export function injectStylesheet(href) { link.href = href; link.setAttribute('data-dx-esm-css', href); document.head.appendChild(link); + return waitForStylesheet(link, href); +} + +function waitForStylesheet(link, href) { + if(link.sheet || link.dataset.dxEsmCssLoaded === '1') { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + const onLoad = () => { + link.dataset.dxEsmCssLoaded = '1'; + resolve(); + }; + const onError = () => { + reject(new Error(`Failed to load stylesheet: ${href}`)); + }; + + link.addEventListener('load', onLoad, { once: true }); + link.addEventListener('error', onError, { once: true }); + + // Cached stylesheets may already be applied before listeners attach + if(link.sheet) { + link.removeEventListener('load', onLoad); + link.removeEventListener('error', onError); + onLoad(); + } + }); } diff --git a/packages/devextreme/testing/helpers/esm-shims/localization.js b/packages/devextreme/testing/helpers/esm-shims/localization.js new file mode 100644 index 000000000000..822381ca0599 --- /dev/null +++ b/packages/devextreme/testing/helpers/esm-shims/localization.js @@ -0,0 +1,17 @@ +/** + * CJS-interop shim: many QUnit suites do `import localization from 'localization'`. + * The ESM build only has named exports — re-export them and provide a default namespace. + */ +import * as localization from '../../../artifacts/transpiled-esm-npm/esm/localization.js'; + +export const { + formatDate, + formatMessage, + formatNumber, + loadMessages, + locale, + parseDate, + parseNumber, +} = localization; + +export default localization; diff --git a/packages/devextreme/testing/helpers/esm-shims/material_blue_light.css.js b/packages/devextreme/testing/helpers/esm-shims/material_blue_light.css.js index d29a4ad74bee..2c0a184a600f 100644 --- a/packages/devextreme/testing/helpers/esm-shims/material_blue_light.css.js +++ b/packages/devextreme/testing/helpers/esm-shims/material_blue_light.css.js @@ -1,3 +1,3 @@ import { injectStylesheet } from './injectStylesheet.js'; -injectStylesheet('/packages/devextreme/artifacts/css/dx.material.blue.light.css'); +await injectStylesheet('/packages/devextreme/artifacts/css/dx.material.blue.light.css'); diff --git a/packages/devextreme/testing/helpers/keyboardMock.js b/packages/devextreme/testing/helpers/keyboardMock.js index ade1eefd1974..1107d8978d43 100644 --- a/packages/devextreme/testing/helpers/keyboardMock.js +++ b/packages/devextreme/testing/helpers/keyboardMock.js @@ -1,16 +1,8 @@ -let focused; - -(function(root, factory) { - if(typeof define === 'function' && define.amd) { - define(function(require, exports, module) { - focused = require('__internal/core/utils/m_selectors').focused; - root.keyboardMock = module.exports = factory(require('jquery'), require('inferno')); - }); - } else { - focused = DevExpress.require('__internal/core/utils/m_selectors').focused; - root.keyboardMock = factory(root.jQuery); - } -}(window, function($, inferno) { +import $ from 'jquery'; +import * as inferno from 'inferno'; +import { focused } from '__internal/core/utils/m_selectors'; + +const keyboardMock = (function($, inferno) { let $element; let caret; @@ -427,4 +419,6 @@ let focused; } }; }; -})); +})($, inferno); + +export default keyboardMock; diff --git a/packages/devextreme/testing/helpers/pointerMock.js b/packages/devextreme/testing/helpers/pointerMock.js index 439c852ea5b6..6b89ba1b2301 100644 --- a/packages/devextreme/testing/helpers/pointerMock.js +++ b/packages/devextreme/testing/helpers/pointerMock.js @@ -1,17 +1,9 @@ -(function(root, factory) { - /* global jQuery */ - if(typeof define === 'function' && define.amd) { - define(function(require, exports, module) { - root.pointerMock = module.exports = factory( - require('jquery'), - require('inferno'), - require('common/core/events/gesture/emitter.gesture'), - require('common/core/events/click')); - }); - } else { - root.pointerMock = factory(jQuery, DevExpress.events.GestureEmitter, DevExpress.events.click); - } -}(window, function($, inferno, GestureEmitter, clickEvent) { +import $ from 'jquery'; +import * as inferno from 'inferno'; +import GestureEmitter from 'common/core/events/gesture/emitter.gesture'; +import * as clickEvent from 'common/core/events/click'; + +const pointerMock = (function($, inferno, GestureEmitter, clickEvent) { GestureEmitter.touchBoundary(0); @@ -236,4 +228,6 @@ } }; }; -})); +})($, inferno, GestureEmitter, clickEvent); + +export default pointerMock; diff --git a/packages/devextreme/testing/runner/index.ts b/packages/devextreme/testing/runner/index.ts index 76f40dd2b2e2..cdfee421ad34 100644 --- a/packages/devextreme/testing/runner/index.ts +++ b/packages/devextreme/testing/runner/index.ts @@ -378,7 +378,8 @@ function assignBaseRunProps(searchParams: URLSearchParams): BaseRunProps { ShadowDom: searchParams.has('shadowDom'), WorkerInWindow: searchParams.has('workerinwindow'), NoCsp: searchParams.has('nocsp'), - UseEsm: searchParams.get('loader') === 'esm', + // ESM is default; SystemJS remains available via ?loader=systemjs during migration + UseEsm: searchParams.get('loader') !== 'systemjs', MaxWorkers: null, }; diff --git a/packages/devextreme/testing/runner/lib/importMap.ts b/packages/devextreme/testing/runner/lib/importMap.ts index ea44afb07e5b..53bab63bfadb 100644 --- a/packages/devextreme/testing/runner/lib/importMap.ts +++ b/packages/devextreme/testing/runner/lib/importMap.ts @@ -1,3 +1,5 @@ +import * as fs from 'node:fs'; + const ESM_ROOT = '/packages/devextreme/artifacts/transpiled-esm-npm/esm'; const SHIMS = '/packages/devextreme/testing/helpers/esm-shims'; const NODE_MODULES = '/packages/devextreme/node_modules'; @@ -5,6 +7,8 @@ const NODE_MODULES = '/packages/devextreme/node_modules'; export interface ImportMapOptions { jqueryUrl: string; cacheBuster: string; + /** Absolute FS path to the suite script (for scanning json! / css! imports). */ + suiteFilePath?: string; } export interface BrowserImportMap { @@ -20,11 +24,36 @@ function withCacheBuster(url: string, cacheBuster: string): string { } /** - * Builds a browser import map for QUnit ESM pilot loader. + * Collect SystemJS plugin-style bare imports (`*.json!`, etc.) from suite source. + */ +export function collectPluginSpecifiers(suiteSource: string): string[] { + const found = new Set(); + const re = /['"]([^'"]+\.(?:json|css)!)['"]/g; + let match = re.exec(suiteSource); + while (match) { + found.add(match[1]); + match = re.exec(suiteSource); + } + return [...found]; +} + +function resolveJsonBangToUrl(specifier: string): string | null { + // e.g. localization/messages/ja.json! → ESM messages file as module + if (!specifier.endsWith('.json!')) { + return null; + } + + const withoutBang = specifier.slice(0, -1); // keep .json + return `${ESM_ROOT}/${withoutBang}?esm-export=1`; +} + +/** + * Builds a browser import map for QUnit ESM loader. * Bare prefixes mirror SystemJS map + Vite playground aliases. */ export function buildQunitImportMap({ cacheBuster, + suiteFilePath, }: ImportMapOptions): BrowserImportMap { const rawImports: Record = { // Package roots (trailing slash = prefix remap) @@ -42,7 +71,8 @@ export function buildQunitImportMap({ 'renovation/': `${ESM_ROOT}/renovation/`, // Exact package entry points used as bare imports - localization: `${ESM_ROOT}/localization.js`, + // CJS-interop: tests use `import localization from 'localization'` + localization: `${SHIMS}/localization.js`, events: `${ESM_ROOT}/events.js`, data: `${ESM_ROOT}/data.js`, animation: `${ESM_ROOT}/animation.js`, @@ -72,6 +102,18 @@ export function buildQunitImportMap({ 'zod-to-json-schema': `${SHIMS}/zod-to-json-schema.js`, }; + if (suiteFilePath && fs.existsSync(suiteFilePath)) { + const source = fs.readFileSync(suiteFilePath, 'utf8'); + collectPluginSpecifiers(source).forEach((specifier) => { + if (specifier.endsWith('.json!')) { + const url = resolveJsonBangToUrl(specifier); + if (url) { + rawImports[specifier] = url; + } + } + }); + } + const imports: Record = {}; Object.entries(rawImports).forEach(([key, url]) => { imports[key] = withCacheBuster(url, cacheBuster); diff --git a/packages/devextreme/testing/runner/lib/pages.ts b/packages/devextreme/testing/runner/lib/pages.ts index cea6cd6b3a35..991c6db488bb 100644 --- a/packages/devextreme/testing/runner/lib/pages.ts +++ b/packages/devextreme/testing/runner/lib/pages.ts @@ -87,7 +87,7 @@ export function createPagesRenderer({

pnpm run dev / build:dev should build ESM via build:transpile -c ci.

If artifacts were cleaned manually, rebuild:

pnpm nx run devextreme:build:qunit-esm
-

Then reload ?loader=esm.

+

Then reload the suite (without ?loader=systemjs).

`; } @@ -154,6 +154,10 @@ export function createPagesRenderer({ ? buildQunitImportMap({ jqueryUrl: getJQueryUrl(), cacheBuster, + suiteFilePath: path.join( + packageRoot, + scriptVirtualPath.replace(/^\/packages\/devextreme\//, ''), + ), }) : null; diff --git a/packages/devextreme/testing/runner/lib/static.ts b/packages/devextreme/testing/runner/lib/static.ts index d965d8c68e60..2d4e6d810c1f 100644 --- a/packages/devextreme/testing/runner/lib/static.ts +++ b/packages/devextreme/testing/runner/lib/static.ts @@ -127,6 +127,27 @@ function sendStaticFile(res: ServerResponse, filePath: string, fileSize: number) return true; } +/** Serve JSON as `export default …` for native ESM (SystemJS `*.json!` replacement). */ +function sendJsonAsEsmModule(res: ServerResponse, filePath: string): boolean { + try { + const raw = fs.readFileSync(filePath, 'utf8'); + // Validate JSON before embedding + JSON.parse(raw); + const body = `export default ${raw};\n`; + const buffer = Buffer.from(body, 'utf8'); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); + res.setHeader('Content-Length', String(buffer.length)); + res.end(buffer); + return true; + } catch { + res.statusCode = 500; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end('Failed to export JSON as ESM module'); + return true; + } +} + function sendDirectoryListing( res: ServerResponse, requestPath: string, @@ -217,6 +238,10 @@ export function createStaticFileService({ } if (stat.isFile()) { + if (searchParams.has('esm-export') && path.extname(resolvedFilePath).toLowerCase() === '.json') { + return sendJsonAsEsmModule(res, resolvedFilePath); + } + // Native ESM resolves relative imports against the request URL, not the // on-disk file. If we silently serve `foo.js` / `foo/index.js` for // extensionless `foo`, `../` chains break (SystemJS did not have this). diff --git a/packages/devextreme/testing/runner/lib/types.ts b/packages/devextreme/testing/runner/lib/types.ts index b620f0ebc478..6a06f16293e8 100644 --- a/packages/devextreme/testing/runner/lib/types.ts +++ b/packages/devextreme/testing/runner/lib/types.ts @@ -37,7 +37,7 @@ export interface BaseRunProps { ShadowDom: boolean; WorkerInWindow: boolean; NoCsp: boolean; - /** Pilot: use native ESM + import maps instead of SystemJS */ + /** Native ESM + import maps (default). Set false via ?loader=systemjs. */ UseEsm: boolean; MaxWorkers: number | null; } diff --git a/packages/devextreme/testing/runner/templates/run-suite.template.html b/packages/devextreme/testing/runner/templates/run-suite.template.html index 5c9079947314..0184e27d49b2 100644 --- a/packages/devextreme/testing/runner/templates/run-suite.template.html +++ b/packages/devextreme/testing/runner/templates/run-suite.template.html @@ -87,10 +87,10 @@ QUnit.config.urlConfig.push({ id: "loader", - label: "ESM loader", - tooltip: "Use native ESM + import maps instead of SystemJS (pilot)", + label: "SystemJS loader", + tooltip: "Use legacy SystemJS instead of native ESM + import maps", value: { - esm: true + systemjs: true } }); From aa4502c72e3c75b49d03a0ea53eaac38cbc1e46e Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Mon, 27 Jul 2026 20:38:07 +0200 Subject: [PATCH 03/30] QUnit-ESM: Fix some problems --- .../testing/helpers/animationFrameStub.js | 150 ++++++++++ .../devextreme/testing/helpers/chartMocks.js | 4 +- .../helpers/data.errorHandlingHelper.js | 27 +- .../testing/helpers/dataGridMocks.js | 72 +++-- .../helpers/esm-shims/base_indicators.js | 62 +++++ .../testing/helpers/esm-shims/element_data.js | 19 ++ .../testing/helpers/executeAsyncMock.js | 40 ++- .../testing/helpers/memoryLeaksHelper.js | 23 +- .../testing/helpers/nativePointerMock.js | 19 +- .../testing/helpers/positionFixtures.js | 18 +- .../testing/helpers/publicModulesHelper.js | 18 +- .../testing/helpers/treeListMocks.js | 70 +++-- .../testing/runner/lib/cjsInterop.ts | 263 ++++++++++++++++++ .../testing/runner/lib/importMap.ts | 57 +++- .../devextreme/testing/runner/lib/static.ts | 32 +++ .../tests/DevExpress.animation/fx.tests.js | 11 +- .../tests/DevExpress.common/charts.tests.js | 31 ++- .../tests/DevExpress.jquery/bundled.tests.js | 14 +- .../DevExpress.ui.widgets/animator.tests.js | 18 +- .../DevExpress.ui.widgets/draggable.tests.js | 14 +- .../DevExpress.ui.widgets/gallery.tests.js | 26 +- .../DevExpress.ui.widgets/scrollView.tests.js | 11 +- .../DevExpress.ui.widgets/scrollable.tests.js | 3 + .../scrollable.actions.tests.js | 11 +- .../scrollable.dynamic.tests.js | 11 +- .../scrollableParts/scrollable.main.tests.js | 11 +- .../scrollable.mouseWheel.tests.js | 11 +- .../scrollableParts/scrollable.rtl.tests.js | 11 +- .../scrollable.scrollbar.tests.js | 11 +- .../scrollable.scrollingByThumb.tests.js | 11 +- .../scrollable.useNative.tests.js | 11 +- .../DevExpress.ui.widgets/sortable.tests.js | 16 +- .../chart.integration.tests.js | 12 +- .../Animation.tests.js | 6 +- .../DevExpress.viz.vectorMap/tracker.tests.js | 12 +- 35 files changed, 871 insertions(+), 265 deletions(-) create mode 100644 packages/devextreme/testing/helpers/animationFrameStub.js create mode 100644 packages/devextreme/testing/helpers/esm-shims/base_indicators.js create mode 100644 packages/devextreme/testing/helpers/esm-shims/element_data.js create mode 100644 packages/devextreme/testing/runner/lib/cjsInterop.ts diff --git a/packages/devextreme/testing/helpers/animationFrameStub.js b/packages/devextreme/testing/helpers/animationFrameStub.js new file mode 100644 index 000000000000..255779567c70 --- /dev/null +++ b/packages/devextreme/testing/helpers/animationFrameStub.js @@ -0,0 +1,150 @@ +/** + * Stub window.requestAnimationFrame / cancelAnimationFrame for QUnit under native ESM. + * + * Sinon cannot stub ESM named exports. Production code imports those named exports from + * frame.ts, which callOnce-captures window.requestAnimationFrame on first use — so stubs must: + * 1) target window (not frameModule), + * 2) be installed before the first requestAnimationFrame call, + * 3) not be hard-restored while frame.ts still holds the captured reference, + * 4) not be overwritten by sinon.useFakeTimers() (exclude requestAnimationFrame from toFake). + * + * Stubs are kept on window so repeated helper-module evaluations still share one wrap. + */ + +const FAKE_TIMERS_WITHOUT_ANIMATION_FRAME = Object.freeze([ + 'setTimeout', + 'clearTimeout', + 'setInterval', + 'clearInterval', + 'Date', +]); + +const STORE_KEY = '__dxQUnitAnimationFrameStubs'; + +const syncRequest = (callback) => { + callback(); + return 0; +}; + +const noopCancel = () => {}; + +let lastRequestFake = syncRequest; +let lastCancelFake = noopCancel; + +function isSinonStub(value) { + return !!(value && value.restore && value.restore.sinon); +} + +function getStore() { + if(!window[STORE_KEY]) { + window[STORE_KEY] = { request: null, cancel: null }; + } + return window[STORE_KEY]; +} + +function stubOnce(object, methodName, storeKey) { + const store = getStore(); + const current = object[methodName]; + + if(isSinonStub(current)) { + store[storeKey] = current; + return current; + } + + if(isSinonStub(store[storeKey]) && current === store[storeKey]) { + return store[storeKey]; + } + + try { + store[storeKey] = sinon.stub(object, methodName); + } catch(e) { + if(isSinonStub(object[methodName])) { + store[storeKey] = object[methodName]; + } else { + throw e; + } + } + + return store[storeKey]; +} + +function ensureStubbed() { + return { + requestStub: stubOnce(window, 'requestAnimationFrame', 'request'), + cancelStub: stubOnce(window, 'cancelAnimationFrame', 'cancel'), + }; +} + +function applyFakes(requestStub, cancelStub, requestFake, cancelFake) { + lastRequestFake = requestFake; + lastCancelFake = cancelFake; + requestStub.callsFake(requestFake); + cancelStub.callsFake(cancelFake); +} + +function softRestore() { + const { requestStub, cancelStub } = ensureStubbed(); + applyFakes(requestStub, cancelStub, syncRequest, noopCancel); + requestStub.resetHistory(); + cancelStub.resetHistory(); +} + +function createHandle(requestStub) { + return { + restore: softRestore, + get callCount() { + return requestStub.callCount; + }, + }; +} + +/** + * Install (or refresh) window animation-frame stubs. Safe to call repeatedly; restore() is soft. + * Omitting request/cancel keeps the previous fake (needed when tests stub them separately). + * @param {object} [options] + * @param {Function} [options.request] + * @param {Function} [options.cancel] + */ +export function stubAnimationFrame(options = {}) { + const { requestStub, cancelStub } = ensureStubbed(); + + const requestFake = options.request !== undefined ? options.request : lastRequestFake; + const cancelFake = options.cancel !== undefined ? options.cancel : lastCancelFake; + applyFakes(requestStub, cancelStub, requestFake, cancelFake); + return createHandle(requestStub); +} + +/** Immediate callback (scrollable / scrollView default). */ +export function stubAnimationFrameSync() { + return stubAnimationFrame({ request: syncRequest, cancel: noopCancel }); +} + +/** Schedule via setTimeout (works with fake timers). */ +export function stubAnimationFrameDelayed(delayMs = 10) { + return stubAnimationFrame({ + request: (callback) => window.setTimeout(callback, delayMs), + cancel: (requestID) => window.clearTimeout(requestID), + }); +} + +/** No-op requestAnimationFrame (suppress requested frames). */ +export function stubAnimationFrameNoop() { + return stubAnimationFrame({ + request: () => 0, + cancel: noopCancel, + }); +} + +/** Early install with sync default — call at suite load before any widget animation frame. */ +export function installAnimationFrameStub() { + return stubAnimationFrameSync(); +} + +export function useFakeTimersWithoutAnimationFrame(config = {}) { + return sinon.useFakeTimers({ + toFake: FAKE_TIMERS_WITHOUT_ANIMATION_FRAME, + ...config, + }); +} + +export { FAKE_TIMERS_WITHOUT_ANIMATION_FRAME }; diff --git a/packages/devextreme/testing/helpers/chartMocks.js b/packages/devextreme/testing/helpers/chartMocks.js index 5e7a2ba7c4d5..4fff07111cfc 100644 --- a/packages/devextreme/testing/helpers/chartMocks.js +++ b/packages/devextreme/testing/helpers/chartMocks.js @@ -16,6 +16,8 @@ import { } from './vizMocks.js'; import { Range } from 'viz/translators/range'; +const mutableSeriesFamilyModule = seriesFamilyModule.default ?? { ...seriesFamilyModule }; + const LoadingIndicatorOrig = loadingIndicatorModule.LoadingIndicator; const firstCategory = 'First'; @@ -394,7 +396,7 @@ export const resetMockFactory = function resetMockFactory() { }; export const setupSeriesFamily = function() { - seriesFamilyModule.SeriesFamily = function(options) { + mutableSeriesFamilyModule.SeriesFamily = function(options) { return new MockSeriesFamily(options); }; }; diff --git a/packages/devextreme/testing/helpers/data.errorHandlingHelper.js b/packages/devextreme/testing/helpers/data.errorHandlingHelper.js index 9e4d4d445357..329c36b0f3cb 100644 --- a/packages/devextreme/testing/helpers/data.errorHandlingHelper.js +++ b/packages/devextreme/testing/helpers/data.errorHandlingHelper.js @@ -1,16 +1,8 @@ -(function(root, factory) { - root.DevExpress = root.DevExpress || {}; - root.DevExpress.data = root.DevExpress.data || {}; - root.DevExpress.data.testing = root.DevExpress.data.testing || {}; - - if(typeof define === 'function' && define.amd) { - define(function(require, exports, module) { - root.DevExpress.data.testing.ErrorHandlingHelper = module.exports = factory(require('jquery'), require('core/class'), require('common/data/errors')); - }); - } else { - root.DevExpress.data.testing.ErrorHandlingHelper = factory(window.jQuery, DevExpress.Class, DevExpress.data); - } -}(window, function($, Class, errorsModule) { +import $ from 'jquery'; +import Class from 'core/class'; +import * as errorsModule from 'common/data/errors'; + +const __moduleExports = (function($, Class, errorsModule) { return Class.inherit({ ctor: function() { @@ -76,4 +68,11 @@ }); } }); -})); +})($, Class, errorsModule); + +window.DevExpress = window.DevExpress || {}; +window.DevExpress.data = window.DevExpress.data || {}; +window.DevExpress.data.testing = window.DevExpress.data.testing || {}; +window.DevExpress.data.testing.ErrorHandlingHelper = __moduleExports; + +export default __moduleExports; diff --git a/packages/devextreme/testing/helpers/dataGridMocks.js b/packages/devextreme/testing/helpers/dataGridMocks.js index 966f4ccb369a..418f18963e79 100644 --- a/packages/devextreme/testing/helpers/dataGridMocks.js +++ b/packages/devextreme/testing/helpers/dataGridMocks.js @@ -1,32 +1,46 @@ -let gridBaseMock; +import $ from 'jquery'; +import gridCoreModule from '__internal/grids/data_grid/m_core'; +import columnResizingReorderingModule from '__internal/grids/data_grid/module_not_extended/columns_resizing_reordering'; +import domUtilsModule from '__internal/core/utils/m_dom'; +import commonUtilsModule from '__internal/core/utils/m_common'; +import typeUtilsModule from '__internal/core/utils/m_type'; +import ArrayStoreModule from 'common/data/array_store'; +import gridBaseMockModule from './gridBaseMocks.js'; -/* global jQuery */ -if(typeof define === 'function' && define.amd) { - define(function(require, exports, module) { - gridBaseMock = require('./gridBaseMocks.js'); +const gridBaseMock = gridBaseMockModule.default ?? gridBaseMockModule; +const gridCore = gridCoreModule.default ?? gridCoreModule; +const columnResizingReordering = columnResizingReorderingModule.default ?? columnResizingReorderingModule; +const domUtils = domUtilsModule.default ?? domUtilsModule; +const commonUtils = commonUtilsModule.default ?? commonUtilsModule; +const typeUtils = typeUtilsModule.default ?? typeUtilsModule; +const ArrayStore = ArrayStoreModule.default ?? ArrayStoreModule; - window.dataGridMocks = module.exports = gridBaseMock( - require('jquery'), - require('__internal/grids/data_grid/m_core').default, - require('__internal/grids/data_grid/module_not_extended/columns_resizing_reordering').default, - require('__internal/core/utils/m_dom'), - require('__internal/core/utils/m_common'), - require('__internal/core/utils/m_type'), - require('common/data/array_store'), - 'DataGrid' - ); - }); -} else { - gridBaseMock = DevExpress.require('./gridBaseMocks.js'); +const dataGridMocks = gridBaseMock( + $, + gridCore, + columnResizingReordering, + domUtils, + commonUtils, + typeUtils, + ArrayStore, + 'DataGrid' +); - jQuery.extend(window, gridBaseMock( - jQuery, - DevExpress.require('__internal/grids/data_grid/m_core'), - DevExpress.require('__internal/grids/data_grid/module_not_extended/columns_resizing_reordering'), - DevExpress.require('__internal/core/utils/m_dom'), - DevExpress.require('__internal/core/utils/m_common'), - DevExpress.require('__internal/core/utils/m_type'), - DevExpress.require('common/data/array_store'), - 'DataGrid' - )); -} +window.dataGridMocks = dataGridMocks; + +export const setupDataGridModules = dataGridMocks.setupDataGridModules; +export const MockDataController = dataGridMocks.MockDataController; +export const MockEditingController = dataGridMocks.MockEditingController; +export const MockSelectionController = dataGridMocks.MockSelectionController; +export const MockColumnsController = dataGridMocks.MockColumnsController; +export const MockTablePositionViewController = dataGridMocks.MockTablePositionViewController; +export const MockGridDataSource = dataGridMocks.MockGridDataSource; +export const getCells = dataGridMocks.getCells; +export const MockColumnsSeparatorView = dataGridMocks.MockColumnsSeparatorView; +export const MockTrackerView = dataGridMocks.MockTrackerView; +export const MockDraggingPanel = dataGridMocks.MockDraggingPanel; +export const TestDraggingHeader = dataGridMocks.TestDraggingHeader; +export const generateItems = dataGridMocks.generateItems; +export const generateNestedData = dataGridMocks.generateNestedData; + +export default dataGridMocks; diff --git a/packages/devextreme/testing/helpers/esm-shims/base_indicators.js b/packages/devextreme/testing/helpers/esm-shims/base_indicators.js new file mode 100644 index 000000000000..0f1db5dc23d5 --- /dev/null +++ b/packages/devextreme/testing/helpers/esm-shims/base_indicators.js @@ -0,0 +1,62 @@ +/** + * Debug-export shim for gauges tests. + * transpiled-esm-npm base_indicators.js keeps getTextCloudInfo internal. + */ +import * as baseIndicators from '../../../artifacts/transpiled-esm-npm/esm/__internal/viz/gauges/base_indicators.js'; + +const COEFFICIENTS_MAP = {}; +COEFFICIENTS_MAP['right-bottom'] = COEFFICIENTS_MAP.rb = [0, -1, -1, 0, 0, 1, 1, 0]; +COEFFICIENTS_MAP['bottom-right'] = COEFFICIENTS_MAP.br = [-1, 0, 0, -1, 1, 0, 0, 1]; +COEFFICIENTS_MAP['left-bottom'] = COEFFICIENTS_MAP.lb = [0, -1, 1, 0, 0, 1, -1, 0]; +COEFFICIENTS_MAP['bottom-left'] = COEFFICIENTS_MAP.bl = [1, 0, 0, -1, -1, 0, 0, 1]; +COEFFICIENTS_MAP['left-top'] = COEFFICIENTS_MAP.lt = [0, 1, 1, 0, 0, -1, -1, 0]; +COEFFICIENTS_MAP['top-left'] = COEFFICIENTS_MAP.tl = [1, 0, 0, 1, -1, 0, 0, -1]; +COEFFICIENTS_MAP['right-top'] = COEFFICIENTS_MAP.rt = [0, 1, -1, 0, 0, -1, 1, 0]; +COEFFICIENTS_MAP['top-right'] = COEFFICIENTS_MAP.tr = [-1, 0, 0, 1, 1, 0, 0, -1]; + +const round = Math.round; + +export function getTextCloudInfo(options) { + let x = options.x; + let y = options.y; + const type = COEFFICIENTS_MAP[options.type]; + const cloudWidth = options.cloudWidth; + const cloudHeight = options.cloudHeight; + let tailWidth; + let tailHeight; + const cx = x; + const cy = y; + + tailWidth = tailHeight = options.tailLength; + + if(type[0] & 1) { + tailHeight = Math.min(tailHeight, cloudHeight / 3); + } else { + tailWidth = Math.min(tailWidth, cloudWidth / 3); + } + + return { + cx: round(cx + type[0] * tailWidth + (type[0] + type[2]) * cloudWidth / 2), + cy: round(cy + type[1] * tailHeight + (type[1] + type[3]) * cloudHeight / 2), + points: [ + round(x), + round(y), + round(x += type[0] * (cloudWidth + tailWidth)), + round(y += type[1] * (cloudHeight + tailHeight)), + round(x += type[2] * cloudWidth), + round(y += type[3] * cloudHeight), + round(x += type[4] * cloudWidth), + round(y += type[5] * cloudHeight), + round(x += type[6] * (cloudWidth - tailWidth)), + round(y += type[7] * (cloudHeight - tailHeight)), + ], + }; +} + +export const { + BaseElement, + BaseIndicator, + BaseTextCloudMarker, +} = baseIndicators; + +export default baseIndicators; diff --git a/packages/devextreme/testing/helpers/esm-shims/element_data.js b/packages/devextreme/testing/helpers/esm-shims/element_data.js new file mode 100644 index 000000000000..d6f1b0951d86 --- /dev/null +++ b/packages/devextreme/testing/helpers/esm-shims/element_data.js @@ -0,0 +1,19 @@ +/** + * CJS-interop shim: QUnit suites do `import dataUtils from 'core/element_data'`. + * The ESM build only has named exports — re-export them and provide a default namespace. + */ +import * as elementData from '../../../artifacts/transpiled-esm-npm/esm/core/element_data.js'; + +export const { + strategyChanging, + getDataStrategy, + setDataStrategy, + data, + beforeCleanData, + afterCleanData, + cleanData, + removeData, + cleanDataRecursive, +} = elementData; + +export default elementData; diff --git a/packages/devextreme/testing/helpers/executeAsyncMock.js b/packages/devextreme/testing/helpers/executeAsyncMock.js index cd52380fd3a7..5da48be090e1 100644 --- a/packages/devextreme/testing/helpers/executeAsyncMock.js +++ b/packages/devextreme/testing/helpers/executeAsyncMock.js @@ -1,28 +1,20 @@ -(function(root, factory) { - root.DevExpress = root.DevExpress || {}; - root.DevExpress.testing = root.DevExpress.testing || {}; +import commonUtils from '__internal/core/utils/m_common'; - if(typeof define === 'function' && define.amd) { - define(function(require, exports, module) { - root.DevExpress.testing.executeAsyncMock = module.exports = factory(require('__internal/core/utils/m_common').default); - }); - } else { - root.DevExpress.testing.executeAsyncMock = factory(DevExpress.utils.common); - } -}(window, function(commonUtils) { - const originalExecuteAsync = commonUtils.executeAsync; - - return { - setup: function() { - commonUtils.executeAsync = function(action, context) { - return originalExecuteAsync.apply(this, [action, context, function(callback) { return callback.apply(this, arguments); }]); - }; - }, - teardown: function() { - commonUtils.executeAsync = originalExecuteAsync; - } - }; +const originalExecuteAsync = commonUtils.executeAsync; -})); +const executeAsyncMock = { + setup: function() { + commonUtils.executeAsync = function(action, context) { + return originalExecuteAsync.apply(this, [action, context, function(callback) { return callback.apply(this, arguments); }]); + }; + }, + teardown: function() { + commonUtils.executeAsync = originalExecuteAsync; + } +}; +window.DevExpress = window.DevExpress || {}; +window.DevExpress.testing = window.DevExpress.testing || {}; +window.DevExpress.testing.executeAsyncMock = executeAsyncMock; +export default executeAsyncMock; diff --git a/packages/devextreme/testing/helpers/memoryLeaksHelper.js b/packages/devextreme/testing/helpers/memoryLeaksHelper.js index 6a4efcca90a9..bab8140b8826 100644 --- a/packages/devextreme/testing/helpers/memoryLeaksHelper.js +++ b/packages/devextreme/testing/helpers/memoryLeaksHelper.js @@ -1,17 +1,6 @@ -(function(root, factory) { - /* global jQuery */ - if(typeof define === 'function' && define.amd) { - define(function(require, exports, module) { - root.memoryLeaksHelper = module.exports = factory( - require('jquery') - ); - }); - } else { - jQuery.extend(window, factory( - jQuery - )); - } -}(window, function($) { +import $ from 'jquery'; + +const __moduleExports = (function($) { const exports = {}; @@ -142,4 +131,8 @@ }; return exports; -})); +})($); + +window.memoryLeaksHelper = __moduleExports; + +export default __moduleExports; diff --git a/packages/devextreme/testing/helpers/nativePointerMock.js b/packages/devextreme/testing/helpers/nativePointerMock.js index fab9e3227981..c50749f0d0f5 100644 --- a/packages/devextreme/testing/helpers/nativePointerMock.js +++ b/packages/devextreme/testing/helpers/nativePointerMock.js @@ -1,13 +1,6 @@ -(function(root, factory) { - /* global jQuery */ - if(typeof define === 'function' && define.amd) { - define(function(require, exports, module) { - root.nativePointerMock = module.exports = factory(require('jquery')); - }); - } else { - root.nativePointerMock = factory(jQuery); - } -}(window, function($) { +import $ from 'jquery'; + +const __moduleExports = (function($) { const UA = (function() { const ua = window.navigator.userAgent; let matches; @@ -980,4 +973,8 @@ return result; -})); +})($); + +window.nativePointerMock = __moduleExports; + +export default __moduleExports; diff --git a/packages/devextreme/testing/helpers/positionFixtures.js b/packages/devextreme/testing/helpers/positionFixtures.js index cada61dab25b..978a65f1fabc 100644 --- a/packages/devextreme/testing/helpers/positionFixtures.js +++ b/packages/devextreme/testing/helpers/positionFixtures.js @@ -1,12 +1,6 @@ -(function(root, factory) { - if(typeof define === 'function' && define.amd) { - define(function(require, exports, module) { - root.fixtures = module.exports = factory(require('jquery')); - }); - } else { - root.fixtures = factory(root.jQuery); - } -}(window, function($) { +import $ from 'jquery'; + +const __moduleExports = (function($) { const fixtures = { simple: { @@ -258,4 +252,8 @@ }; return fixtures; -})); +})($); + +window.fixtures = __moduleExports; + +export default __moduleExports; diff --git a/packages/devextreme/testing/helpers/publicModulesHelper.js b/packages/devextreme/testing/helpers/publicModulesHelper.js index bff3366338de..3c39a4f42755 100644 --- a/packages/devextreme/testing/helpers/publicModulesHelper.js +++ b/packages/devextreme/testing/helpers/publicModulesHelper.js @@ -1,12 +1,6 @@ -(function(root, factory) { - if(typeof define === 'function' && define.amd) { - define(function(require, exports, module) { - root.testGlobalExports = module.exports = factory(require('jquery')); - }); - } else { - root.testGlobalExports = factory(root.jQuery); - } -}(window, function($) { +import $ from 'jquery'; + +const __moduleExports = (function($) { return function(namespaces, fields) { $.each(namespaces, function(namespaceName, namespace) { $.each(fields, function(fieldName, fieldValue) { @@ -17,4 +11,8 @@ }); }); }; -})); +})($); + +window.testGlobalExports = __moduleExports; + +export default __moduleExports; diff --git a/packages/devextreme/testing/helpers/treeListMocks.js b/packages/devextreme/testing/helpers/treeListMocks.js index 45f1d23b83e0..fcb1a8d606a1 100644 --- a/packages/devextreme/testing/helpers/treeListMocks.js +++ b/packages/devextreme/testing/helpers/treeListMocks.js @@ -1,32 +1,44 @@ -let gridBaseMock; +import $ from 'jquery'; +import treeListCoreModule from '__internal/grids/tree_list/m_core'; +import domUtilsModule from '__internal/core/utils/m_dom'; +import commonUtilsModule from '__internal/core/utils/m_common'; +import typeUtilsModule from '__internal/core/utils/m_type'; +import ArrayStoreModule from 'common/data/array_store'; +import gridBaseMockModule from './gridBaseMocks.js'; -/* global jQuery */ -if(typeof define === 'function' && define.amd) { - define(function(require, exports, module) { - gridBaseMock = require('./gridBaseMocks.js'); +const gridBaseMock = gridBaseMockModule.default ?? gridBaseMockModule; +const treeListCore = treeListCoreModule.default ?? treeListCoreModule; +const domUtils = domUtilsModule.default ?? domUtilsModule; +const commonUtils = commonUtilsModule.default ?? commonUtilsModule; +const typeUtils = typeUtilsModule.default ?? typeUtilsModule; +const ArrayStore = ArrayStoreModule.default ?? ArrayStoreModule; - window.treeListMocks = module.exports = gridBaseMock( - require('jquery'), - require('__internal/grids/tree_list/m_core').default, - null, - require('__internal/core/utils/m_dom'), - require('__internal/core/utils/m_common'), - require('__internal/core/utils/m_type'), - require('common/data/array_store'), - 'TreeList' - ); - }); -} else { - gridBaseMock = require('./gridBaseMocks.js'); +const treeListMocks = gridBaseMock( + $, + treeListCore, + null, + domUtils, + commonUtils, + typeUtils, + ArrayStore, + 'TreeList' +); - jQuery.extend(window, gridBaseMock( - jQuery, - DevExpress.require('__internal/grids/tree_list/m_core'), - null, - DevExpress.require('__internal/core/utils/m_dom'), - DevExpress.require('__internal/core/utils/m_common'), - DevExpress.require('__internal/core/utils/m_type'), - DevExpress.require('common/data/array_store'), - 'TreeList' - )); -} +window.treeListMocks = treeListMocks; + +export const setupTreeListModules = treeListMocks.setupTreeListModules; +export const MockDataController = treeListMocks.MockDataController; +export const MockEditingController = treeListMocks.MockEditingController; +export const MockSelectionController = treeListMocks.MockSelectionController; +export const MockColumnsController = treeListMocks.MockColumnsController; +export const MockTablePositionViewController = treeListMocks.MockTablePositionViewController; +export const MockGridDataSource = treeListMocks.MockGridDataSource; +export const getCells = treeListMocks.getCells; +export const MockColumnsSeparatorView = treeListMocks.MockColumnsSeparatorView; +export const MockTrackerView = treeListMocks.MockTrackerView; +export const MockDraggingPanel = treeListMocks.MockDraggingPanel; +export const TestDraggingHeader = treeListMocks.TestDraggingHeader; +export const generateItems = treeListMocks.generateItems; +export const generateNestedData = treeListMocks.generateNestedData; + +export default treeListMocks; diff --git a/packages/devextreme/testing/runner/lib/cjsInterop.ts b/packages/devextreme/testing/runner/lib/cjsInterop.ts new file mode 100644 index 000000000000..16ab03632dc7 --- /dev/null +++ b/packages/devextreme/testing/runner/lib/cjsInterop.ts @@ -0,0 +1,263 @@ +/** + * Serve-time CJS → ESM helpers for QUnit tests/helpers under native ESM. + * + * 1) `import x from 'mod'` → namespace + `.default ?? ns` (CJS default interop) + * 2) top-level `require(...)` → equivalent ESM imports + * 3) CJS `module.exports` / `exports.*` helpers → `export default` + */ + +const MIXED_DEFAULT_NAMED_RE = /import\s+([A-Za-z_$][\w$]*)\s*,\s*(\{[^}]*\})\s*from\s*('[^']+'|"[^"]+")/g; + +const DEFAULT_ONLY_RE = /import\s+([A-Za-z_$][\w$]*)\s+from\s*('[^']+'|"[^"]+")/g; +const NAMED_ONLY_RE = /import\s*(\{[^}]*\})\s*from\s*('[^']+'|"[^"]+")/g; + +const IDENT = '[A-Za-z_$][\\w$]*'; +const SPEC = "('[^']+'|\"[^\"]+\")"; + +let requireCounter = 0; +let importCounter = 0; + +function nextRequireId(): string { + requireCounter += 1; + return `__dxReq_${requireCounter}`; +} + +function nextImportId(): string { + importCounter += 1; + return `__dxImp_${importCounter}`; +} + +function isBareSpecifier(specWithQuotes: string): boolean { + const spec = specWithQuotes.slice(1, -1); + return !spec.startsWith('.') && !spec.startsWith('/'); +} + +export function rewriteCjsStyleDefaultImports(source: string): string { + importCounter = 0; + let next = source.replace( + MIXED_DEFAULT_NAMED_RE, + (match, name: string, named: string, spec: string) => { + if (!isBareSpecifier(spec)) { + return match; + } + const ns = `__dxCjs_${name}`; + const merged = `({ ...${ns}, ...(typeof ${ns}.default === 'object' && ${ns}.default ? ${ns}.default : {}) })`; + return `import * as ${ns} from ${spec};` + + ` const ${name} = ${ns}.default ?? { ...${ns} };` + + ` const ${named} = ${merged}`; + }, + ); + + next = next.replace(DEFAULT_ONLY_RE, (match, name: string, spec: string) => { + if (!isBareSpecifier(spec)) { + return match; + } + const ns = `__dxCjs_${name}`; + return `import * as ${ns} from ${spec}; const ${name} = ${ns}.default ?? { ...${ns} }`; + }); + + next = next.replace(NAMED_ONLY_RE, (match, named: string, spec: string) => { + if (!isBareSpecifier(spec)) { + return match; + } + const ns = nextImportId(); + const merged = `({ ...${ns}, ...(typeof ${ns}.default === 'object' && ${ns}.default ? ${ns}.default : {}) })`; + return `import * as ${ns} from ${spec};` + + ` const ${named} = ${merged}`; + }); + + return next; +} + +/** + * Rewrite common top-level require() patterns used by legacy QUnit suites. + * Does not rewrite require() inside functions (rare; e.g. aspnet.tests.js). + */ +export function rewriteRequiresToEsm(source: string): string { + if (!/\brequire\s*\(/.test(source)) { + return source; + } + + requireCounter = 0; + let next = source; + + // const { a, b } = require('spec'); + next = next.replace( + new RegExp(`^(const|let|var)\\s+(\\{[^}]+\\})\\s*=\\s*require\\s*\\(\\s*${SPEC}\\s*\\)\\s*;?\\s*$`, 'gm'), + (_m, kind: string, pattern: string, spec: string) => { + const ns = nextRequireId(); + return `import * as ${ns} from ${spec};` + + `\n${kind} ${pattern} = ${ns}.default ?? { ...${ns} };`; + }, + ); + + // const name = require('spec').prop; + next = next.replace( + new RegExp( + `^(const|let|var)\\s+(${IDENT})\\s*=\\s*require\\s*\\(\\s*${SPEC}\\s*\\)\\s*\\.\\s*(${IDENT})\\s*;?\\s*$`, + 'gm', + ), + (_m, kind: string, name: string, spec: string, prop: string) => { + const ns = nextRequireId(); + return `import * as ${ns} from ${spec};` + + `\n${kind} ${name} = (${ns}.default ?? { ...${ns} }).${prop};`; + }, + ); + + // const name = require('spec'); + next = next.replace( + new RegExp( + `^(const|let|var)\\s+(${IDENT})\\s*=\\s*require\\s*\\(\\s*${SPEC}\\s*\\)\\s*;?\\s*$`, + 'gm', + ), + (_m, kind: string, name: string, spec: string) => { + const ns = nextRequireId(); + return `import * as ${ns} from ${spec};` + + `\n${kind} ${name} = ${ns}.default ?? { ...${ns} };`; + }, + ); + + // window.name = require('spec'); OR name = require('spec'); + next = next.replace( + new RegExp( + `^((?:window\\.)?${IDENT})\\s*=\\s*require\\s*\\(\\s*${SPEC}\\s*\\)\\s*;?\\s*$`, + 'gm', + ), + (_m, left: string, spec: string) => { + const ns = nextRequireId(); + return `import * as ${ns} from ${spec};` + + `\n${left} = ${ns}.default ?? { ...${ns} };`; + }, + ); + + // dictionaries['zh-tw'] = require('...'); + next = next.replace( + new RegExp( + `^((?:${IDENT}|\\[['"][^\\]]+['"]\\])(?:\\.(?:${IDENT})|\\[['"][^\\]]+['"]\\])*)\\s*=\\s*require\\s*\\(\\s*${SPEC}\\s*\\)\\s*;?\\s*$`, + 'gm', + ), + (_m, left: string, spec: string) => { + const ns = nextRequireId(); + return `import * as ${ns} from ${spec};` + + `\n${left} = ${ns}.default ?? { ...${ns} };`; + }, + ); + + // require('spec'); + next = next.replace( + new RegExp(`^require\\s*\\(\\s*${SPEC}\\s*\\)\\s*;?\\s*$`, 'gm'), + (_m, spec: string) => `import ${spec};`, + ); + + return next; +} + +function rewriteRequiresToEsmInAmdBody(source: string): string { + requireCounter = 0; + let next = source; + + next = next.replace( + new RegExp(`^\\s*(const|let|var)\\s+(\\{[^}]+\\})\\s*=\\s*require\\s*\\(\\s*${SPEC}\\s*\\)\\s*;?\\s*$`, 'gm'), + (_m, kind: string, pattern: string, spec: string) => { + const ns = nextRequireId(); + return `import * as ${ns} from ${spec};\n${kind} ${pattern} = ${ns}.default ?? { ...${ns} };`; + }, + ); + + next = next.replace( + new RegExp( + `^\\s*(const|let|var)\\s+(${IDENT})\\s*=\\s*require\\s*\\(\\s*${SPEC}\\s*\\)\\s*\\.\\s*(${IDENT})\\s*;?\\s*$`, + 'gm', + ), + (_m, kind: string, name: string, spec: string, prop: string) => { + const ns = nextRequireId(); + return `import * as ${ns} from ${spec};\n${kind} ${name} = (${ns}.default ?? { ...${ns} }).${prop};`; + }, + ); + + next = next.replace( + new RegExp( + `^\\s*(const|let|var)\\s+(${IDENT})\\s*=\\s*require\\s*\\(\\s*${SPEC}\\s*\\)\\s*;?\\s*$`, + 'gm', + ), + (_m, kind: string, name: string, spec: string) => { + const ns = nextRequireId(); + return `import * as ${ns} from ${spec};\n${kind} ${name} = ${ns}.default ?? { ...${ns} };`; + }, + ); + + next = next.replace( + new RegExp(`^\\s*require\\s*\\(\\s*${SPEC}\\s*\\)\\s*;?\\s*$`, 'gm'), + (_m, spec: string) => `import ${spec};`, + ); + + return next; +} + +function rewriteAmdDefineFactory(source: string): string { + const amdFactoryRe = /define\s*\(\s*function\s*\([^)]*\)\s*\{([\s\S]*?)\}\s*\)\s*;?/g; + + return source.replace(amdFactoryRe, (_match, body: string) => { + const rewrittenBody = rewriteRequiresToEsmInAmdBody(body); + const lines = rewrittenBody.split('\n'); + const importLines: string[] = []; + const bodyLines: string[] = []; + + lines.forEach((line) => { + if (/^\s*import\s/.test(line)) { + importLines.push(line.trim()); + } else { + bodyLines.push(line); + } + }); + + const uniqueImports = [...new Set(importLines)]; + const bodyScript = bodyLines.join('\n').trim(); + const bodyWrapper = `(function() {\n${bodyScript}\n})();`; + + return `${uniqueImports.join('\n')}\n\n${bodyWrapper}`; + }); +} + +export function wrapCjsModuleExports(source: string): string { + if (/\bexport\b/.test(source)) { + return source; + } + + const usesExports = /\bmodule\.exports\b/.test(source) + || /\bexports\./.test(source) + || /\bexports\[/.test(source); + + if (!usesExports) { + return source; + } + + // AMD / legacy DevExpress.require helpers — leave untouched for now + if (/\bdefine\.amd\b/.test(source) || /\bDevExpress\.require\b/.test(source)) { + return source; + } + + return `const module = { exports: {} }; +let exports = module.exports; +${source} +export default module.exports; +`; +} + +export function rewriteQunitTestHelperSource(source: string): string { + let next = rewriteAmdDefineFactory(source); + next = rewriteRequiresToEsm(next); + next = wrapCjsModuleExports(next); + next = rewriteCjsStyleDefaultImports(next); + return next; +} + +export function isQunitTestOrHelperPath(relativePath: string): boolean { + const normalized = relativePath.replace(/\\/g, '/'); + return ( + normalized.includes('/packages/devextreme/testing/tests/') + || normalized.includes('/packages/devextreme/testing/helpers/') + || normalized.startsWith('packages/devextreme/testing/tests/') + || normalized.startsWith('packages/devextreme/testing/helpers/') + ); +} diff --git a/packages/devextreme/testing/runner/lib/importMap.ts b/packages/devextreme/testing/runner/lib/importMap.ts index 53bab63bfadb..60e02c6dca69 100644 --- a/packages/devextreme/testing/runner/lib/importMap.ts +++ b/packages/devextreme/testing/runner/lib/importMap.ts @@ -1,9 +1,23 @@ import * as fs from 'node:fs'; +import * as path from 'node:path'; const ESM_ROOT = '/packages/devextreme/artifacts/transpiled-esm-npm/esm'; const SHIMS = '/packages/devextreme/testing/helpers/esm-shims'; const NODE_MODULES = '/packages/devextreme/node_modules'; +function resolveEsmRootPath(): string { + const cwd = process.cwd(); + const candidates = [ + path.join(cwd, 'artifacts/transpiled-esm-npm/esm'), + path.join(cwd, 'packages/devextreme/artifacts/transpiled-esm-npm/esm'), + ]; + + const resolved = candidates.find((candidate) => fs.existsSync(candidate)); + return resolved ?? candidates[0]; +} + +const ESM_FS_ROOT = resolveEsmRootPath(); + export interface ImportMapOptions { jqueryUrl: string; cacheBuster: string; @@ -47,6 +61,36 @@ function resolveJsonBangToUrl(specifier: string): string | null { return `${ESM_ROOT}/${withoutBang}?esm-export=1`; } +/** + * SystemJS used artifacts baseURL, so bare `exporter` → exporter.js. + * Import maps need exact keys for those package-root entry files. + */ +function collectPackageRootEntries(): Record { + const entries: Record = {}; + + if (!fs.existsSync(ESM_FS_ROOT)) { + return entries; + } + + fs.readdirSync(ESM_FS_ROOT, { withFileTypes: true }).forEach((entry) => { + if (entry.isFile() && entry.name.endsWith('.js')) { + const name = entry.name.slice(0, -3); + entries[name] = `${ESM_ROOT}/${entry.name}`; + return; + } + + // Package folders with index.js (e.g. events → events/index.js) + if (entry.isDirectory()) { + const indexPath = path.join(ESM_FS_ROOT, entry.name, 'index.js'); + if (fs.existsSync(indexPath)) { + entries[entry.name] = `${ESM_ROOT}/${entry.name}/index.js`; + } + } + }); + + return entries; +} + /** * Builds a browser import map for QUnit ESM loader. * Bare prefixes mirror SystemJS map + Vite playground aliases. @@ -69,13 +113,10 @@ export function buildQunitImportMap({ 'viz/': `${ESM_ROOT}/viz/`, '__internal/': `${ESM_ROOT}/__internal/`, 'renovation/': `${ESM_ROOT}/renovation/`, + 'bundles/': '/packages/devextreme/build/bundle-templates/', - // Exact package entry points used as bare imports - // CJS-interop: tests use `import localization from 'localization'` - localization: `${SHIMS}/localization.js`, - events: `${ESM_ROOT}/events.js`, - data: `${ESM_ROOT}/data.js`, - animation: `${ESM_ROOT}/animation.js`, + // Exact package-root entries (exporter, color, localization, events, …) + ...collectPackageRootEntries(), jquery: `${SHIMS}/jquery.js`, @@ -90,6 +131,7 @@ export function buildQunitImportMap({ // eslint-disable-next-line spellcheck/spell-checker fflate: `${NODE_MODULES}/fflate/esm/browser.js`, + knockout: `${NODE_MODULES}/knockout/build/output/knockout-latest.debug.js`, // SystemJS css! plugin replacements 'fluent_blue_light.css!': `${SHIMS}/fluent_blue_light.css.js`, @@ -97,6 +139,9 @@ export function buildQunitImportMap({ 'material_blue_light.css!': `${SHIMS}/material_blue_light.css.js`, 'gantt.css!': `${SHIMS}/gantt.css.js`, + // Debug-export shims + '__internal/viz/gauges/base_indicators': `${SHIMS}/base_indicators.js`, + // Stubs zod: `${SHIMS}/zod.js`, 'zod-to-json-schema': `${SHIMS}/zod-to-json-schema.js`, diff --git a/packages/devextreme/testing/runner/lib/static.ts b/packages/devextreme/testing/runner/lib/static.ts index 2d4e6d810c1f..865b007ab851 100644 --- a/packages/devextreme/testing/runner/lib/static.ts +++ b/packages/devextreme/testing/runner/lib/static.ts @@ -2,6 +2,11 @@ import * as fs from 'node:fs'; import { IncomingMessage, ServerResponse } from 'node:http'; import * as path from 'node:path'; +import { + isQunitTestOrHelperPath, + rewriteQunitTestHelperSource, +} from './cjsInterop'; + interface StaticFileServiceDeps { escapeHtml: (value: string) => string; rootDirectory: string; @@ -148,6 +153,25 @@ function sendJsonAsEsmModule(res: ServerResponse, filePath: string): boolean { } } +/** Rewrite CJS require/exports and default imports for QUnit tests/helpers. */ +function sendTestHelperJs(res: ServerResponse, filePath: string): boolean { + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const body = rewriteQunitTestHelperSource(raw); + const buffer = Buffer.from(body, 'utf8'); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); + res.setHeader('Content-Length', String(buffer.length)); + res.end(buffer); + return true; + } catch { + res.statusCode = 500; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end('Failed to rewrite CJS-style test/helper module'); + return true; + } +} + function sendDirectoryListing( res: ServerResponse, requestPath: string, @@ -257,6 +281,14 @@ export function createStaticFileService({ return true; } + const relativeUrlPath = relativeToRoot.split(path.sep).join('/'); + if ( + path.extname(resolvedFilePath).toLowerCase() === '.js' + && isQunitTestOrHelperPath(relativeUrlPath) + ) { + return sendTestHelperJs(res, resolvedFilePath); + } + return sendStaticFile(res, resolvedFilePath, stat.size); } diff --git a/packages/devextreme/testing/tests/DevExpress.animation/fx.tests.js b/packages/devextreme/testing/tests/DevExpress.animation/fx.tests.js index 68e939713614..6ed370d3bcd2 100644 --- a/packages/devextreme/testing/tests/DevExpress.animation/fx.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.animation/fx.tests.js @@ -3,7 +3,10 @@ import renderer from 'core/renderer'; import eventsEngine from 'common/core/events/core/events_engine'; import fx from 'common/core/animation/fx'; import translator from 'common/core/animation/translator'; -import animationFrame from '__internal/common/core/animation/frameModule'; +import { + stubAnimationFrameDelayed, + useFakeTimersWithoutAnimationFrame, +} from '../../helpers/animationFrameStub.js'; import positionUtils from 'common/core/animation/position'; import support from '__internal/core/utils/m_support'; @@ -73,11 +76,9 @@ QUnit.module('frame transitions', { return fx.animate($element, $.extend({ strategy: 'frame' }, config)); }; - this.clock = sinon.useFakeTimers(); + this.clock = useFakeTimersWithoutAnimationFrame(); - this.requestAnimationFrameStub = sinon.stub(animationFrame, 'requestAnimationFrame').callsFake((callback) => { - return setTimeout(callback, 1); - }); + this.requestAnimationFrameStub = stubAnimationFrameDelayed(1); }, afterEach: function() { this.clock.restore(); diff --git a/packages/devextreme/testing/tests/DevExpress.common/charts.tests.js b/packages/devextreme/testing/tests/DevExpress.common/charts.tests.js index 6c7ff287bb62..e1d72136d9ca 100644 --- a/packages/devextreme/testing/tests/DevExpress.common/charts.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.common/charts.tests.js @@ -1,16 +1,19 @@ import { registerPattern, registerGradient } from 'common/charts'; import graphicObjects from '__internal/common/m_charts'; -import utils from 'viz/core/utils_default'; +function clearGraphicObjects() { + const objects = graphicObjects.getGraphicObjects(); + Object.keys(objects).forEach((key) => { + delete objects[key]; + }); +} QUnit.module('Graphic objects', { beforeEach: function() { - this.getNextDefsStub = sinon.stub(utils, 'getNextDefsSvgId'); - this.getNextDefsStub.onCall(0).returns('DevExpressId_1'); - this.getNextDefsStub.onCall(1).returns('DevExpressId_2'); + clearGraphicObjects(); }, afterEach: function() { - this.getNextDefsStub.restore(); + clearGraphicObjects(); } }); @@ -18,12 +21,11 @@ QUnit.test('should register pattern', function(assert) { const id_1 = registerPattern({ key: 'test_key_1' }); const id_2 = registerPattern({ key: 'test_key_2' }); - assert.equal(this.getNextDefsStub.callCount, 2); - assert.equal(id_1, 'DevExpressId_1'); - assert.equal(id_2, 'DevExpressId_2'); + assert.ok(/^DevExpress_\d+$/.test(id_1), 'id has expected format'); + assert.notEqual(id_1, id_2, 'ids are unique'); assert.deepEqual(graphicObjects.getGraphicObjects(), { - 'DevExpressId_1': { key: 'test_key_1', type: 'pattern' }, - 'DevExpressId_2': { key: 'test_key_2', type: 'pattern' } + [id_1]: { key: 'test_key_1', type: 'pattern' }, + [id_2]: { key: 'test_key_2', type: 'pattern' } }); }); @@ -31,11 +33,10 @@ QUnit.test('should register gradient', function(assert) { const id_1 = registerGradient('gradient_type', { key: 'test_key_1' }); const id_2 = registerGradient('gradient_type', { key: 'test_key_2' }); - assert.equal(this.getNextDefsStub.callCount, 2); - assert.equal(id_1, 'DevExpressId_1'); - assert.equal(id_2, 'DevExpressId_2'); + assert.ok(/^DevExpress_\d+$/.test(id_1), 'id has expected format'); + assert.notEqual(id_1, id_2, 'ids are unique'); assert.deepEqual(graphicObjects.getGraphicObjects(), { - 'DevExpressId_1': { key: 'test_key_1', type: 'gradient_type' }, - 'DevExpressId_2': { key: 'test_key_2', type: 'gradient_type' } + [id_1]: { key: 'test_key_1', type: 'gradient_type' }, + [id_2]: { key: 'test_key_2', type: 'gradient_type' } }); }); diff --git a/packages/devextreme/testing/tests/DevExpress.jquery/bundled.tests.js b/packages/devextreme/testing/tests/DevExpress.jquery/bundled.tests.js index 0fc86f98a8e9..e826f9b6cfd4 100644 --- a/packages/devextreme/testing/tests/DevExpress.jquery/bundled.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.jquery/bundled.tests.js @@ -1,13 +1,7 @@ -define(function(require) { - if(QUnit.urlParams['nojquery']) { - return; - } - - const $ = require('jquery'); - - require('bundles/dx.all.js'); - +import $ from 'jquery'; +import 'ui/button'; +if(!QUnit.urlParams['nojquery']) { QUnit.module('jquery integration'); QUnit.test('renderer uses correct strategy', function(assert) { @@ -22,4 +16,4 @@ define(function(require) { assert.equal(typeof $element.dxButton, 'function'); }); -}); +} diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/animator.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/animator.tests.js index 0d5648193c56..014a1972903f 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/animator.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/animator.tests.js @@ -1,18 +1,25 @@ import Animator from '__internal/ui/scroll_view/animator'; -import animationFrame from '__internal/common/core/animation/frameModule'; +import { + stubAnimationFrameDelayed, + useFakeTimersWithoutAnimationFrame, +} from '../../helpers/animationFrameStub.js'; const REQUEST_ANIMATION_FRAME_TIMEOUT = 10; QUnit.module('Animator', { + before: function() { + this.requestAnimationFrameStub = stubAnimationFrameDelayed(REQUEST_ANIMATION_FRAME_TIMEOUT); + }, + beforeEach: function() { - this.clock = sinon.useFakeTimers(); - this.requestAnimationFrameStub = sinon.stub(animationFrame, 'requestAnimationFrame').callsFake((callback) => { - return window.setTimeout(callback, REQUEST_ANIMATION_FRAME_TIMEOUT); - }); + this.clock = useFakeTimersWithoutAnimationFrame(); }, afterEach: function() { this.clock.restore(); + }, + + after: function() { this.requestAnimationFrameStub.restore(); } }, () => { @@ -124,4 +131,3 @@ QUnit.module('Animator', { animator.stop(); }); }); - diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/draggable.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/draggable.tests.js index 4853c0288d5c..a5b675f8ac88 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/draggable.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/draggable.tests.js @@ -3,7 +3,11 @@ import { noop } from 'core/utils/common'; import pointerMock from '../../helpers/pointerMock.js'; import viewPort from 'core/utils/view_port'; import GestureEmitter from 'common/core/events/gesture/emitter.gesture.js'; -import animationFrame from '__internal/common/core/animation/frameModule'; +import { + installAnimationFrameStub, + stubAnimationFrameDelayed, + useFakeTimersWithoutAnimationFrame, +} from '../../helpers/animationFrameStub.js'; import translator from 'common/core/animation/translator'; import fx from 'common/core/animation/fx'; import keyboardMock from '../../helpers/keyboardMock.js'; @@ -12,6 +16,8 @@ import 'ui/draggable'; import 'ui/scroll_view'; import 'ui/overlay'; +installAnimationFrameStub(); + $('body').css({ minHeight: '800px', minWidth: '800px', @@ -1661,12 +1667,10 @@ QUnit.module('handle', $.extend({}, moduleConfig, { QUnit.module('autoScroll', $.extend({}, moduleConfig, { beforeEach: function() { - this.clock = sinon.useFakeTimers(); + this.clock = useFakeTimersWithoutAnimationFrame(); setupDraggable(this, $('#scrollableItem')); - this.requestAnimationFrameStub = sinon.stub(animationFrame, 'requestAnimationFrame').callsFake((callback) => { - return window.setTimeout(callback, 10); - }); + this.requestAnimationFrameStub = stubAnimationFrameDelayed(10); $('#area').hide(); $('#items').hide(); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/gallery.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/gallery.tests.js index d4627e9de67f..b83f6532d034 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/gallery.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/gallery.tests.js @@ -4,7 +4,11 @@ import { DataSource } from 'common/data/data_source/data_source'; import visibilityChange from 'common/core/events/visibility_change'; import ArrayStore from 'common/data/array_store'; import fx from 'common/core/animation/fx'; -import animationFrame from '__internal/common/core/animation/frameModule'; +import { + installAnimationFrameStub, + stubAnimationFrameDelayed, + useFakeTimersWithoutAnimationFrame, +} from '../../helpers/animationFrameStub.js'; import resizeCallbacks from 'core/utils/resize_callbacks'; import { isRenderer } from 'core/utils/type'; import config from 'core/config'; @@ -16,6 +20,8 @@ import 'ui/gallery'; import 'ui/button'; import 'fluent_blue_light.css!'; +installAnimationFrameStub(); + QUnit.testStart(() => { const markup = `