From dbc880636d3041c474ea8ef7c06ecde931a5a1e6 Mon Sep 17 00:00:00 2001 From: Eugen Zha Date: Wed, 4 Mar 2026 19:08:32 +0200 Subject: [PATCH 01/11] Get rid of dotnet test runner --- packages/devextreme/docker-ci.sh | 8 +- packages/devextreme/package.json | 2 +- packages/devextreme/testing/launch | 4 +- packages/devextreme/testing/runner/index.js | 2433 +++++++++++++++++++ 4 files changed, 2440 insertions(+), 7 deletions(-) create mode 100644 packages/devextreme/testing/runner/index.js diff --git a/packages/devextreme/docker-ci.sh b/packages/devextreme/docker-ci.sh index d6edd0bcbb33..18ab388f1777 100755 --- a/packages/devextreme/docker-ci.sh +++ b/packages/devextreme/docker-ci.sh @@ -6,7 +6,7 @@ # # 1. GITHUBACTION=true (GitHub Actions) # - Runs NATIVELY on GitHub runner (NO Docker container!) -# - Uses pre-installed Chrome and dotnet +# - Uses pre-installed Chrome and Node.js # - Dependencies already installed by workflow # - Fastest and most stable mode # @@ -79,8 +79,8 @@ function run_test_impl { pnpm run build fi - echo "Starting ASP.NET Core test runner..." - dotnet ./testing/runner/bin/runner.dll --single-run & runner_pid=$! + echo "Starting Node.js test runner..." + node ./testing/runner/index.js --single-run & runner_pid=$! echo "Runner PID: $runner_pid" local max_attempts=30 @@ -241,7 +241,7 @@ function start_runner_watchdog { echo "Watchdog running in background (PID: $watchdog_pid)" } -echo "node $(node -v), pnpm $(pnpm -v), dotnet $(dotnet --version)" +echo "node $(node -v), pnpm $(pnpm -v)" TARGET_FUNC="run_$TARGET" diff --git a/packages/devextreme/package.json b/packages/devextreme/package.json index 191833110bf5..94290e02b87b 100644 --- a/packages/devextreme/package.json +++ b/packages/devextreme/package.json @@ -237,7 +237,7 @@ "build:testing-playground": "gulp build-renovation-testing:watch --playgroundName", "build:community-localization": "gulp generate-community-locales", "build:systemjs": "gulp transpile-systemjs", - "dev": "dotnet build build/build-dotnet.sln && cross-env DEVEXTREME_TEST_CI=true gulp dev", + "dev": "cross-env DEVEXTREME_TEST_CI=true gulp dev", "dev:watch": "cross-env DEVEXTREME_TEST_CI=true gulp dev-watch", "transpile-tests": "gulp transpile-tests", "update-ts-reexports": "dx-tools generate-reexports --sources ./js --exclude \"((dialog|export|list_light|notify|overlay|palette|set_template_engine|splitter_control|themes|themes_callback|track_bar|utils|validation_engine|validation_message)[.d.ts])\" --compiler-options \"{ \\\"typeRoots\\\": [] }\"", diff --git a/packages/devextreme/testing/launch b/packages/devextreme/testing/launch index 04e9421f6c32..f82e48f6ced0 100755 --- a/packages/devextreme/testing/launch +++ b/packages/devextreme/testing/launch @@ -15,8 +15,8 @@ execRunner(); function execRunner () { spawn( - 'dotnet', - [ join(__dirname, 'runner/bin/runner.dll') ], + 'node', + [ join(__dirname, 'runner/index.js') ], { stdio: 'inherit', shell: true } ); diff --git a/packages/devextreme/testing/runner/index.js b/packages/devextreme/testing/runner/index.js new file mode 100644 index 000000000000..ff84ec244082 --- /dev/null +++ b/packages/devextreme/testing/runner/index.js @@ -0,0 +1,2433 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const http = require('http'); +const { spawn, spawnSync } = require('child_process'); + +const KNOWN_CONSTELLATIONS = new Set(['export', 'misc', 'ui', 'ui.widgets', 'ui.editors', 'ui.grid', 'ui.scheduler']); + +const PACKAGE_ROOT = path.resolve(__dirname, '../..'); +const REPO_ROOT = path.resolve(PACKAGE_ROOT, '../..'); +const TESTING_ROOT = path.join(PACKAGE_ROOT, 'testing'); +const TESTS_ROOT = path.join(TESTING_ROOT, 'tests'); +const VECTOR_DATA_DIRECTORY = path.join(TESTING_ROOT, 'content', 'VectorMapData'); + +const COMPLETED_SUITES_FILENAME = path.join(TESTING_ROOT, 'CompletedSuites.txt'); +const LAST_SUITE_TIME_FILENAME = path.join(TESTING_ROOT, 'LastSuiteTime.txt'); +const RESULTS_XML_FILENAME = path.join(TESTING_ROOT, 'Results.xml'); +const MISC_ERRORS_FILENAME = path.join(TESTING_ROOT, 'MiscErrors.log'); +const RAW_LOG_FILENAME = path.join(TESTING_ROOT, 'RawLog.txt'); + +const RUN_FLAGS = { + singleRun: process.argv.includes('--single-run'), + isContinuousIntegration: isContinuousIntegration(), +}; + +const PORTS = loadPorts(path.join(PACKAGE_ROOT, 'ports.json')); +const QUNIT_PORT = Number(PORTS.qunit); +const VECTOR_MAP_TESTER_PORT = Number(PORTS['vectormap-utils-tester']); + +const PATH_TO_NODE = resolveNodePath(); + +const logger = createRawLogger(RAW_LOG_FILENAME); + +const vectorMapNodeServer = { + process: null, + refs: 0, + killTimer: null, +}; + +start(); + +function start() { + const server = http.createServer((req, res) => { + handleRequest(req, res).catch((error) => { + writeError(error && error.stack ? error.stack : String(error)); + if(!res.headersSent) { + setNoCacheHeaders(res); + res.statusCode = 500; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + } + if(!res.writableEnded) { + res.end('Internal Server Error'); + } + }); + }); + + server.listen(QUNIT_PORT, '0.0.0.0', () => { + writeLine(`QUnit runner server listens on http://0.0.0.0:${QUNIT_PORT}...`); + }); +} + +async function handleRequest(req, res) { + const requestUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + const pathname = safeDecodeURIComponent(requestUrl.pathname); + const pathnameLower = pathname.toLowerCase(); + + if(req.method === 'GET' && (pathname === '/' || pathnameLower === '/main/index')) { + return sendHtml(res, renderIndexPage()); + } + + if(req.method === 'GET') { + const suitesJsonMatch = pathname.match(/^\/Main\/SuitesJson(?:\/(.+))?$/i); + if(suitesJsonMatch) { + const id = suitesJsonMatch[1] + ? safeDecodeURIComponent(suitesJsonMatch[1]) + : requestUrl.searchParams.get('id'); + const suites = readSuites(id || ''); + return sendJson(res, suites); + } + } + + if(req.method === 'GET' && pathnameLower === '/main/categoriesjson') { + return sendJson(res, readCategories()); + } + + if(req.method === 'GET' && (pathnameLower === '/run' || pathnameLower === '/run/' || pathnameLower === '/main/runall')) { + const model = buildRunAllModel(requestUrl.searchParams); + const runProps = assignBaseRunProps(requestUrl.searchParams); + return sendHtml(res, renderRunAllPage(model, runProps)); + } + + if(req.method === 'GET') { + const runSuiteMatch = pathname.match(/^\/run\/([^/]+)\/(.+\.js)$/i); + if(runSuiteMatch) { + const catName = safeDecodeURIComponent(runSuiteMatch[1]); + const suiteName = safeDecodeURIComponent(runSuiteMatch[2]); + const model = buildRunSuiteModel(catName, suiteName); + const runProps = assignBaseRunProps(requestUrl.searchParams); + return sendHtml(res, renderRunSuitePage(model, runProps, requestUrl.searchParams)); + } + } + + if(req.method === 'GET' && pathnameLower === '/main/runsuite') { + const catName = requestUrl.searchParams.get('catName') || ''; + const suiteName = requestUrl.searchParams.get('suiteName') || ''; + + if(!catName || !suiteName) { + return sendNotFound(res); + } + + const model = buildRunSuiteModel(catName, suiteName); + const runProps = assignBaseRunProps(requestUrl.searchParams); + return sendHtml(res, renderRunSuitePage(model, runProps, requestUrl.searchParams)); + } + + if(req.method === 'POST' && pathnameLower === '/main/notifyteststarted') { + const form = await readFormBody(req); + const name = String(form.name || ''); + + try { + writeLine(` [ run] ${name}`); + } catch(_) { + // Ignore logging errors. + } + + return sendText(res, 'OK'); + } + + if(req.method === 'POST' && pathnameLower === '/main/notifytestcompleted') { + const form = await readFormBody(req); + const name = String(form.name || ''); + const passed = parseBoolean(form.passed); + + try { + writeLine(` [${passed ? ' ok' : 'fail'}] ${name}`); + } catch(_) { + // Ignore logging errors. + } + + return sendText(res, 'OK'); + } + + if(req.method === 'POST' && pathnameLower === '/main/notifysuitefinalized') { + const form = await readFormBody(req); + const name = String(form.name || ''); + const passed = parseBoolean(form.passed); + const runtime = parseNumber(form.runtime); + + try { + if(passed && RUN_FLAGS.isContinuousIntegration) { + fs.appendFileSync(COMPLETED_SUITES_FILENAME, `${name}${os.EOL}`); + } + + if(RUN_FLAGS.isContinuousIntegration) { + writeLastSuiteTime(); + } + + write(passed ? '[ OK ' : '[FAIL', passed ? 'green' : 'red'); + const seconds = Number((runtime / 1000).toFixed(3)); + writeLine(`] ${name} in ${seconds}s`); + } catch(_) { + // Preserve legacy behavior: swallow errors. + } + + return sendText(res, 'OK'); + } + + if(req.method === 'POST' && pathnameLower === '/main/notifyisalive') { + try { + if(RUN_FLAGS.isContinuousIntegration) { + writeLastSuiteTime(); + } + } catch(_) { + // Preserve legacy behavior: swallow errors. + } + + return sendText(res, 'OK'); + } + + if(req.method === 'POST' && pathnameLower === '/main/saveresults') { + return saveResults(req, res); + } + + if(req.method === 'GET' && pathnameLower === '/main/displayresults') { + const stylesheetUrl = '/packages/devextreme/testing/content/unittests.xsl'; + const xml = [ + '', + ``, + '', + safeReadFile(RESULTS_XML_FILENAME), + '', + '', + ].join('\n'); + + return sendXml(res, xml); + } + + if(req.method === 'POST' && pathnameLower === '/main/logmiscerror') { + const form = await readFormBody(req); + const message = String(form.msg || ''); + logMiscErrorCore(message); + return sendText(res, 'OK'); + } + + if(req.method === 'GET' && pathnameLower === '/themes-test/get-css-files-list') { + const list = readThemeCssFiles(); + return sendJson(res, list); + } + + if(req.method === 'GET' && pathnameLower === '/testvectormapdata/gettestdata') { + const data = readVectorMapTestData(); + return sendJson(res, data); + } + + if(req.method === 'GET') { + const parseBufferMatch = pathname.match(/^\/TestVectorMapData\/ParseBuffer\/(.+)$/i); + if(parseBufferMatch) { + const id = safeDecodeURIComponent(parseBufferMatch[1]); + const responseText = await redirectRequestToVectorMapNodeServer('parse-buffer', id); + return sendJsonText(res, responseText); + } + } + + if(req.method === 'GET') { + const readAndParseMatch = pathname.match(/^\/TestVectorMapData\/ReadAndParse\/(.+)$/i); + if(readAndParseMatch) { + const id = safeDecodeURIComponent(readAndParseMatch[1]); + const responseText = await redirectRequestToVectorMapNodeServer('read-and-parse', id); + return sendJsonText(res, responseText); + } + } + + if(req.method === 'GET') { + const executeConsoleAppMatch = pathname.match(/^\/TestVectorMapData\/ExecuteConsoleApp(?:\/(.*))?$/i); + if(executeConsoleAppMatch) { + const arg = safeDecodeURIComponent(executeConsoleAppMatch[1] || ''); + const result = executeVectorMapConsoleApp(arg, requestUrl.searchParams); + return sendJson(res, result); + } + } + + if(await tryServeStatic(req, res, pathname, requestUrl.searchParams)) { + return; + } + + return sendNotFound(res); +} + +function buildRunSuiteModel(catName, suiteName) { + return { + Title: suiteName, + ScriptVirtualPath: getSuiteVirtualPath(catName, suiteName), + }; +} + +function buildRunAllModel(searchParams) { + let includeSet = null; + let excludeSet = null; + let excludeSuites = null; + let partIndex = 0; + let partCount = 1; + + let constellation = searchParams.get('constellation'); + const include = searchParams.get('include'); + const exclude = searchParams.get('exclude'); + + if(include) { + includeSet = new Set(splitCommaList(include)); + } + + if(exclude) { + excludeSet = new Set(splitCommaList(exclude)); + } + + if(constellation && constellation.includes('(') && constellation.endsWith(')')) { + const [name, partInfo] = constellation.slice(0, -1).split('('); + const parts = partInfo.split('/'); + + constellation = name; + partIndex = Number(parts[0]) - 1; + partCount = Number(parts[1]); + } + + if(RUN_FLAGS.isContinuousIntegration && fs.existsSync(COMPLETED_SUITES_FILENAME)) { + const completedSuites = fs.readFileSync(COMPLETED_SUITES_FILENAME, 'utf8') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + excludeSuites = new Set(completedSuites); + } + + const packageJson = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')); + + return { + Constellation: constellation || '', + CategoriesList: include || '', + Version: String(packageJson.version || ''), + Suites: getAllSuites({ + deviceMode: hasDeviceModeFlag(searchParams), + constellation: constellation || '', + includeCategories: includeSet, + excludeCategories: excludeSet, + excludeSuites, + partIndex, + partCount, + }), + }; +} + +function assignBaseRunProps(searchParams) { + const result = { + IsContinuousIntegration: RUN_FLAGS.isContinuousIntegration, + NoGlobals: searchParams.has('noglobals'), + NoTimers: searchParams.has('notimers'), + NoTryCatch: searchParams.has('notrycatch'), + NoJQuery: searchParams.has('nojquery'), + ShadowDom: searchParams.has('shadowDom'), + WorkerInWindow: searchParams.has('workerinwindow'), + NoCsp: searchParams.has('nocsp'), + MaxWorkers: null, + }; + + if(process.env.MAX_WORKERS && /^\d+$/.test(process.env.MAX_WORKERS)) { + result.MaxWorkers = Number(process.env.MAX_WORKERS); + } + + return result; +} + +function hasDeviceModeFlag(searchParams) { + return searchParams.has('deviceMode'); +} + +function readCategories() { + const dirs = fs.readdirSync(TESTS_ROOT, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(TESTS_ROOT, entry.name)) + .filter(isNotEmptyDir) + .map(categoryFromPath) + .sort((a, b) => a.Name.localeCompare(b.Name)); + + return dirs; +} + +function readSuites(catName) { + if(!catName) { + throw new Error('Category name is required.'); + } + + const catPath = path.join(TESTS_ROOT, catName); + + const subDirs = fs.readdirSync(catPath, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + + subDirs.forEach((dirName) => { + if(!dirName.endsWith('Parts')) { + throw new Error(`Unexpected sub-directory in the test category: ${path.join(catPath, dirName)}`); + } + }); + + const suites = fs.readdirSync(catPath, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.js')) + .map((entry) => suiteFromPath(catName, path.join(catPath, entry.name))) + .sort((a, b) => a.ShortName.localeCompare(b.ShortName)); + + return suites; +} + +function getSuiteVirtualPath(catName, suiteName) { + return `/packages/devextreme/testing/tests/${catName}/${suiteName}`; +} + +function getAllSuites({ + deviceMode, + constellation, + includeCategories, + excludeCategories, + excludeSuites, + partIndex, + partCount, +}) { + const includeSpecified = includeCategories && includeCategories.size > 0; + const excludeSpecified = excludeCategories && excludeCategories.size > 0; + const result = []; + + readCategories().forEach((category) => { + if(deviceMode && !category.RunOnDevices) { + return; + } + + if(constellation && category.Constellation !== constellation) { + return; + } + + if(includeSpecified && !includeCategories.has(category.Name)) { + return; + } + + if(category.Explicit && (!includeSpecified || !includeCategories.has(category.Name))) { + return; + } + + if(excludeSpecified && excludeCategories.has(category.Name)) { + return; + } + + let index = 0; + readSuites(category.Name).forEach((suite) => { + if(partCount > 1 && (index % partCount) !== partIndex) { + index += 1; + return; + } + + index += 1; + + if(excludeSuites && excludeSuites.has(suite.FullName)) { + return; + } + + result.push(suite); + }); + }); + + return result; +} + +function categoryFromPath(categoryPath) { + const name = path.basename(categoryPath); + const metaPath = path.join(categoryPath, '__meta.json'); + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); + const constellation = String(meta.constellation || ''); + + if(!KNOWN_CONSTELLATIONS.has(constellation)) { + throw new Error(`Unknown constellation (group of categories):${constellation}`); + } + + return { + Name: name, + Constellation: constellation, + Explicit: Boolean(meta.explicit), + RunOnDevices: Boolean(meta.runOnDevices), + }; +} + +function suiteFromPath(catName, suitePath) { + const suiteName = path.basename(suitePath); + const shortName = path.basename(suitePath, '.js'); + + return { + ShortName: shortName, + FullName: `${catName}/${suiteName}`, + Url: `/run/${encodeURIComponent(catName)}/${encodeURIComponent(suiteName)}`, + }; +} + +function isNotEmptyDir(dirPath) { + try { + return fs.readdirSync(dirPath).length > 0; + } catch(_) { + return false; + } +} + +function renderIndexPage() { + const rootUrl = '/'; + const suitesJsonUrl = '/Main/SuitesJson'; + const categoriesJsonUrl = '/Main/CategoriesJson'; + const jqueryUrl = '/packages/devextreme/artifacts/js/jquery.js'; + const knockoutUrl = '/packages/devextreme/artifacts/js/knockout-latest.js'; + + return ` + + + + + + + +
+
+ Categories +
+
+
+ Select: + all, + none + | RUN +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + +
+ Greyed are explicit tests +
+
+ +
+
+ Suites for category "" + (Refresh) +
+
+
+ Run all suites +
+ +
+ + + + +`; +} + +function renderRunAllPage(model, runProps) { + const jqueryUrl = '/packages/devextreme/artifacts/js/jquery.js'; + + return ` + + + QUnit All Suites test page + + + + + + + + + +
+ +
+
+
+

+

+
+
+ +`; +} + +function renderRunSuitePage(model, runProps, searchParams) { + const scriptVirtualPath = model.ScriptVirtualPath; + const isNoJQueryTest = scriptVirtualPath.includes('nojquery'); + const isServerSideTest = scriptVirtualPath.includes('DevExpress.serverSide'); + const isSelfSufficientTest = scriptVirtualPath.includes('_bundled') + || scriptVirtualPath.includes('Bundles') + || scriptVirtualPath.includes('DevExpress.jquery'); + + const cspPart = runProps.NoCsp ? '' : '-systemjs'; + const npmModule = `transpiled${cspPart}`; + const testingBasePath = runProps.NoCsp + ? '/packages/devextreme/testing/' + : '/packages/devextreme/artifacts/transpiled-testing/'; + + function getJQueryUrl() { + if(isNoJQueryTest) { + return `${testingBasePath}helpers/noJQuery.js`; + } + + return '/packages/devextreme/artifacts/js/jquery.js'; + } + + function getTestUrl() { + if(runProps.NoCsp) { + return scriptVirtualPath; + } + + return scriptVirtualPath.replace('/testing/', '/artifacts/transpiled-testing/'); + } + + function getJQueryIntegrationImports() { + const result = []; + + if(!isSelfSufficientTest) { + if(runProps.NoJQuery || isNoJQueryTest || isServerSideTest) { + result.push(`${testingBasePath}helpers/jQueryEventsPatch.js`); + result.push(`${testingBasePath}helpers/argumentsValidator.js`); + result.push(`${testingBasePath}helpers/dataPatch.js`); + result.push(`/packages/devextreme/artifacts/${npmModule}/__internal/integration/jquery/component_registrator.js`); + } else { + result.push(`/packages/devextreme/artifacts/${npmModule}/integration/jquery.js`); + } + } + + if(isServerSideTest) { + result.push(`${testingBasePath}helpers/ssrEmulator.js`); + } + + return result; + } + + const cacheBuster = getCacheBuster(searchParams); + + const qunitCss = contentWithCacheBuster('/packages/devextreme/node_modules/qunit/qunit/qunit.css', cacheBuster); + const qunitJs = contentWithCacheBuster('/packages/devextreme/node_modules/qunit/qunit/qunit.js', cacheBuster); + 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 cspMap = !runProps.NoCsp + ? { + 'inferno-create-element': '/packages/devextreme/node_modules/inferno-create-element/dist/inferno-create-element.js', + intl: '/packages/devextreme/artifacts/js-systemjs/intl/index.js', + knockout: '/packages/devextreme/artifacts/js-systemjs/knockout.js', + css: '/packages/devextreme/artifacts/js-systemjs/css.js', + 'generic_light.css': '/packages/devextreme/artifacts/css-systemjs/dx.light.css', + 'material_blue_light.css': '/packages/devextreme/artifacts/css-systemjs/dx.material.blue.light.css', + 'fluent_blue_light.css': '/packages/devextreme/artifacts/css-systemjs/dx.fluent.blue.light.css', + 'gantt.css': '/packages/devextreme/artifacts/css-systemjs/dx-gantt.css', + 'devextreme-cldr-data': '/packages/devextreme/artifacts/js-systemjs/devextreme-cldr-data', + 'cldr-core': '/packages/devextreme/artifacts/js-systemjs/cldr-core', + json: '/packages/devextreme/artifacts/js-systemjs/json.js', + '@@preact/signals-core': '/packages/devextreme/artifacts/js-systemjs/preact-signals.js', + } + : { + 'devextreme-cldr-data': '/packages/devextreme/node_modules/devextreme-cldr-data', + 'cldr-core': '/packages/devextreme/node_modules/cldr-core', + '@@preact/signals-core': '/packages/devextreme/node_modules/@preact/signals-core/dist/signals-core.js', + }; + + const systemMap = { + globalize: '/packages/devextreme/node_modules/globalize/dist/globalize', + intl: '/packages/devextreme/node_modules/intl/index.js', + cldr: '/packages/devextreme/node_modules/cldrjs/dist/cldr', + jquery: getJQueryUrl(), + knockout: '/packages/devextreme/node_modules/knockout/build/output/knockout-latest.debug.js', + jszip: '/packages/devextreme/artifacts/js/jszip.js', + underscore: '/packages/devextreme/node_modules/underscore/underscore-min.js', + '@@devextreme/vdom': '/packages/devextreme/node_modules/@devextreme/vdom', + 'devextreme-quill': '/packages/devextreme/node_modules/devextreme-quill/dist/dx-quill.js', + 'devexpress-diagram': '/packages/devextreme/artifacts/js/dx-diagram.js', + 'devexpress-gantt': '/packages/devextreme/artifacts/js/dx-gantt.js', + 'devextreme-exceljs-fork': '/packages/devextreme/node_modules/devextreme-exceljs-fork/dist/dx-exceljs-fork.js', + 'fflate': '/packages/devextreme/node_modules/fflate/esm/browser.js', + jspdf: '/packages/devextreme/node_modules/jspdf/dist/jspdf.umd.js', + 'jspdf-autotable': '/packages/devextreme/node_modules/jspdf-autotable/dist/jspdf.plugin.autotable.js', + rrule: '/packages/devextreme/node_modules/rrule/dist/es5/rrule.js', + inferno: '/packages/devextreme/node_modules/inferno/dist/inferno.js', + 'inferno-hydrate': '/packages/devextreme/node_modules/inferno-hydrate/dist/inferno-hydrate.js', + 'inferno-compat': '/packages/devextreme/node_modules/inferno-compat/dist/inferno-compat.js', + 'inferno-clone-vnode': '/packages/devextreme/node_modules/inferno-clone-vnode/dist/index.cjs.js', + 'inferno-create-element': '/packages/devextreme/node_modules/inferno-create-element/dist/index.cjs.js', + 'inferno-create-class': '/packages/devextreme/node_modules/inferno-create-class/dist/index.cjs.js', + 'inferno-extras': '/packages/devextreme/node_modules/inferno-extras/dist/index.cjs.js', + 'generic_light.css': '/packages/devextreme/artifacts/css/dx.light.css', + 'material_blue_light.css': '/packages/devextreme/artifacts/css/dx.material.blue.light.css', + 'fluent_blue_light.css': '/packages/devextreme/artifacts/css/dx.fluent.blue.light.css', + 'gantt.css': '/packages/devextreme/artifacts/css/dx-gantt.css', + css: '/packages/devextreme/node_modules/systemjs-plugin-css/css.js', + text: '/packages/devextreme/node_modules/systemjs-plugin-text/text.js', + json: '/packages/devextreme/node_modules/systemjs-plugin-json/json.js', + 'plugin-babel': '/packages/devextreme/node_modules/systemjs-plugin-babel/plugin-babel.js', + 'systemjs-babel-build': '/packages/devextreme/node_modules/systemjs-plugin-babel/systemjs-babel-browser.js', + ...cspMap, + }; + + const systemPackages = { + '': { + defaultExtension: 'js', + }, + globalize: { + main: '../globalize.js', + defaultExtension: 'js', + }, + cldr: { + main: '../cldr.js', + defaultExtension: 'js', + }, + 'common/core/events/utils': { + main: 'index', + }, + 'events/utils': { + main: 'index', + }, + events: { + main: 'index', + }, + }; + + const knockoutPath = '/packages/devextreme/node_modules/knockout/build/output/knockout-latest.debug.js'; + + const systemConfig = { + baseURL: `/packages/devextreme/artifacts/${npmModule}`, + transpiler: 'plugin-babel', + map: systemMap, + packages: systemPackages, + packageConfigPaths: [ + '@@devextreme/*/package.json', + ], + meta: { + [knockoutPath]: { + format: 'global', + deps: ['jquery'], + exports: 'ko', + }, + '*.js': { + babelOptions: { + es2015: false, + }, + }, + }, + }; + + const integrationImportPaths = getJQueryIntegrationImports(); + + return ` + + ${runProps.NoCsp ? '' : ``} + ${escapeHtml(model.Title)} - QUnit test page + + + + + + + + + + + + + + + + + + + +
+
+ + +`; +} + +async function saveResults(req, res) { + let hasFailure = false; + let xml = ''; + + try { + const json = await readBodyText(req); + validateResultsJson(json); + + const results = JSON.parse(json); + hasFailure = Number(results.failures) > 0; + xml = testResultsToXml(results); + + if(RUN_FLAGS.singleRun) { + writeLine(); + printTextReport(results); + } + } catch(error) { + logMiscErrorCore(`Failed to save results. ${error && error.stack ? error.stack : String(error)}`); + hasFailure = true; + } + + fs.writeFileSync(RESULTS_XML_FILENAME, xml, 'utf8'); + + sendText(res, 'OK'); + + if(RUN_FLAGS.singleRun) { + setTimeout(() => { + process.exit(hasFailure ? 1 : 0); + }, 0); + } +} + +function validateResultsJson(json) { + const badToken = '\\u0000'; + const badIndex = json.indexOf(badToken); + + if(badIndex > -1) { + const from = Math.max(0, badIndex - 200); + const to = Math.min(json.length, badIndex + 200); + throw new Error(`Result JSON has bad content: ${json.slice(from, to)}`); + } +} + +function printTextReport(results) { + const maxWrittenFailures = 50; + const notRunCases = []; + const failedCases = []; + + (results.suites || []).forEach((suite) => { + enumerateAllCases(suite, (testCase) => { + if(testCase && testCase.reason) { + notRunCases.push(testCase); + } + if(testCase && testCase.failure) { + failedCases.push(testCase); + } + }); + }); + + const total = Number(results.total) || 0; + const failures = Number(results.failures) || 0; + const notRunCount = notRunCases.length; + const color = failures > 0 ? 'red' : (notRunCount > 0 ? 'yellow' : 'green'); + + writeLine(`Tests run: ${total}, Failures: ${failures}, Not run: ${notRunCount}`, color); + + if(notRunCount > 0 && failures === 0) { + notRunCases.forEach((testCase) => { + writeLine('-'.repeat(80)); + writeLine(`Skipped: ${testCase.name || ''}`); + writeLine(`Reason: ${testCase.reason && testCase.reason.message ? testCase.reason.message : ''}`); + }); + } + + if(failures > 0) { + let writtenFailures = 0; + + failedCases.forEach((testCase) => { + if(writtenFailures >= maxWrittenFailures) { + return; + } + + writeLine('-'.repeat(80)); + writeLine(testCase.name || '', 'white'); + writeLine(); + writeLine(testCase.failure && testCase.failure.message ? testCase.failure.message : ''); + + writtenFailures += 1; + }); + + if(writtenFailures >= maxWrittenFailures) { + writeLine(`WARNING: only first ${maxWrittenFailures} failures are shown.`); + } + } +} + +function enumerateAllCases(suite, callback) { + (suite.results || []).forEach((item) => { + if(item && Array.isArray(item.results)) { + enumerateAllCases(item, callback); + return; + } + + callback(item); + }); +} + +function testResultsToXml(results) { + const lines = []; + + lines.push(``); + + (results.suites || []).forEach((suite) => { + lines.push(renderSuiteXml(suite, ' ')); + }); + + lines.push(''); + + return `${lines.join('\n')}\n`; +} + +function renderSuiteXml(suite, indent) { + const lines = []; + + lines.push(`${indent}`); + lines.push(`${indent} `); + + (suite.results || []).forEach((item) => { + if(item && Array.isArray(item.results)) { + lines.push(renderSuiteXml(item, `${indent} `)); + } else { + lines.push(renderCaseXml(item || {}, `${indent} `)); + } + }); + + lines.push(`${indent} `); + lines.push(`${indent}`); + + return lines.join('\n'); +} + +function renderCaseXml(testCase, indent) { + const attributes = [ + `name="${escapeXmlAttr(testCase.name || '')}"`, + `url="${escapeXmlAttr(testCase.url || '')}"`, + `time="${escapeXmlAttr(testCase.time || '')}"`, + ]; + + if(testCase.executed === false) { + attributes.push('executed="false"'); + } + + const hasFailure = Boolean(testCase.failure && typeof testCase.failure.message === 'string'); + const hasReason = Boolean(testCase.reason && typeof testCase.reason.message === 'string'); + + if(!hasFailure && !hasReason) { + return `${indent}`; + } + + const lines = [`${indent}`]; + + if(hasFailure) { + lines.push(`${indent} `); + lines.push(`${indent} ${escapeXmlText(testCase.failure.message)}`); + lines.push(`${indent} `); + } + + if(hasReason) { + lines.push(`${indent} `); + lines.push(`${indent} ${escapeXmlText(testCase.reason.message)}`); + lines.push(`${indent} `); + } + + lines.push(`${indent}`); + + return lines.join('\n'); +} + +function normalizeNumber(value) { + const number = Number(value); + if(Number.isNaN(number)) { + return 0; + } + + return number; +} + +function readThemeCssFiles() { + const bundlesPath = path.join(PACKAGE_ROOT, 'scss', 'bundles'); + const result = []; + + fs.readdirSync(bundlesPath, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .forEach((entry) => { + const bundleDirectory = path.join(bundlesPath, entry.name); + fs.readdirSync(bundleDirectory, { withFileTypes: true }) + .filter((file) => file.isFile() && file.name.endsWith('.scss')) + .forEach((file) => { + result.push(`${path.basename(file.name, '.scss')}.css`); + }); + }); + + return result; +} + +function readVectorMapTestData() { + if(!fs.existsSync(VECTOR_DATA_DIRECTORY)) { + return []; + } + + return fs.readdirSync(VECTOR_DATA_DIRECTORY, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.txt')) + .map((entry) => { + const filePath = path.join(VECTOR_DATA_DIRECTORY, entry.name); + return { + name: path.basename(entry.name, '.txt'), + expected: fs.readFileSync(filePath, 'utf8'), + }; + }); +} + +async function redirectRequestToVectorMapNodeServer(action, arg) { + acquireVectorMapNodeServer(); + + try { + const startTime = Date.now(); + + while(true) { + try { + const text = await httpGetText(`http://127.0.0.1:${VECTOR_MAP_TESTER_PORT}/${action}/${arg}`); + return text; + } catch(error) { + if(Date.now() - startTime > 5000) { + throw error; + } + } + } + } finally { + releaseVectorMapNodeServer(); + } +} + +function acquireVectorMapNodeServer() { + if(vectorMapNodeServer.killTimer) { + clearTimeout(vectorMapNodeServer.killTimer); + vectorMapNodeServer.killTimer = null; + } + + if(!vectorMapNodeServer.process || vectorMapNodeServer.process.killed) { + const scriptPath = path.join(TESTING_ROOT, 'helpers', 'vectormaputils-tester.js'); + + vectorMapNodeServer.process = spawn( + PATH_TO_NODE, + [scriptPath, `${VECTOR_DATA_DIRECTORY}${path.sep}`], + { + stdio: 'ignore', + }, + ); + + vectorMapNodeServer.process.on('exit', () => { + if(vectorMapNodeServer.process && vectorMapNodeServer.process.exitCode !== null) { + vectorMapNodeServer.process = null; + } + }); + } + + vectorMapNodeServer.refs += 1; +} + +function releaseVectorMapNodeServer() { + vectorMapNodeServer.refs -= 1; + + if(vectorMapNodeServer.refs <= 0) { + vectorMapNodeServer.refs = 0; + + vectorMapNodeServer.killTimer = setTimeout(() => { + if(vectorMapNodeServer.refs === 0 && vectorMapNodeServer.process) { + try { + vectorMapNodeServer.process.kill(); + } catch(_) { + // Ignore process kill failures. + } + vectorMapNodeServer.process = null; + } + vectorMapNodeServer.killTimer = null; + }, 200); + } +} + +function executeVectorMapConsoleApp(arg, searchParams) { + const inputDirectory = `${path.join(PACKAGE_ROOT, 'testing', 'content', 'VectorMapData')}${path.sep}`; + const outputDirectory = path.join(inputDirectory, '__Output'); + const settingsPath = path.join(inputDirectory, '_settings.js'); + const processFileContentPath = path.join(inputDirectory, '_processFileContent.js'); + const vectorMapUtilsNodePath = path.resolve(path.join(PACKAGE_ROOT, 'artifacts/js/vectormap-utils/dx.vectormaputils.node.js')); + + const args = [vectorMapUtilsNodePath, inputDirectory]; + + if(searchParams.has('file')) { + args[1] += searchParams.get('file'); + } + + args.push('--quiet', '--output', outputDirectory, '--settings', settingsPath, '--process-file-content', processFileContentPath); + + const isJson = searchParams.has('json'); + + if(isJson) { + args.push('--json'); + } + + fs.mkdirSync(outputDirectory, { recursive: true }); + + try { + const spawnResult = spawnSync(PATH_TO_NODE, args, { + timeout: 15000, + stdio: 'ignore', + }); + + if(spawnResult.error && spawnResult.error.code === 'ETIMEDOUT') { + // Intentionally ignored to match legacy behavior. + } + + const extension = isJson ? '.json' : '.js'; + + return fs.readdirSync(outputDirectory, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(extension)) + .map((entry) => { + const filePath = path.join(outputDirectory, entry.name); + let text = fs.readFileSync(filePath, 'utf8'); + let variable = null; + + if(!isJson) { + const index = text.indexOf('='); + if(index > 0) { + variable = text.substring(0, index).trim(); + text = text.substring(index + 1, text.length - 2).trim(); + } + } + + return { + file: `${path.basename(entry.name, extension)}${extension}`, + variable, + content: JSON.parse(text), + }; + }); + } finally { + try { + fs.rmSync(outputDirectory, { recursive: true, force: true }); + } catch(_) { + // Ignore cleanup errors. + } + } +} + +function tryServeStatic(req, res, pathname, searchParams) { + const normalizedPath = pathname === '/' ? '/' : pathname.replace(/\/+$/, ''); + const relativePath = normalizedPath.replace(/^\/+/, ''); + const filePath = path.resolve(path.join(REPO_ROOT, relativePath)); + const relativeToRoot = path.relative(REPO_ROOT, filePath); + + if(relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) { + setNoCacheHeaders(res); + res.statusCode = 403; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end('Forbidden'); + return true; + } + + if(!fs.existsSync(filePath)) { + return false; + } + + setStaticCacheHeaders(res, searchParams); + + const stat = fs.statSync(filePath); + + if(stat.isDirectory()) { + return sendDirectoryListing(res, pathname, filePath); + } + + if(stat.isFile()) { + return sendStaticFile(res, filePath, stat.size); + } + + return false; +} + +function sendStaticFile(res, filePath, fileSize) { + res.statusCode = 200; + res.setHeader('Content-Type', getContentType(filePath)); + res.setHeader('Content-Length', String(fileSize)); + + const stream = fs.createReadStream(filePath); + stream.pipe(res); + + stream.on('error', () => { + if(!res.headersSent) { + res.statusCode = 500; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + } + if(!res.writableEnded) { + res.end('Internal Server Error'); + } + }); + + return true; +} + +function sendDirectoryListing(res, requestPath, dirPath) { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + const pathname = requestPath.endsWith('/') ? requestPath : `${requestPath}/`; + + const items = []; + + if(pathname !== '/') { + const parentPath = pathname + .split('/') + .filter(Boolean) + .slice(0, -1) + .join('/'); + const href = parentPath ? `/${parentPath}/` : '/'; + items.push(`
  • ..
  • `); + } + + entries + .sort((a, b) => a.name.localeCompare(b.name)) + .forEach((entry) => { + const suffix = entry.isDirectory() ? '/' : ''; + const href = `${pathname}${encodeURIComponent(entry.name)}${suffix}`; + items.push(`
  • ${escapeHtml(entry.name)}${suffix}
  • `); + }); + + const html = ` + + + +Index of ${escapeHtml(pathname)} + + +

    Index of ${escapeHtml(pathname)}

    + + +`; + + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(html); + + return true; +} + +function readBodyText(req) { + return new Promise((resolve, reject) => { + const chunks = []; + + req.on('data', (chunk) => { + chunks.push(chunk); + }); + + req.on('end', () => { + resolve(Buffer.concat(chunks).toString('utf8')); + }); + + req.on('error', reject); + }); +} + +async function readFormBody(req) { + const body = await readBodyText(req); + return Object.fromEntries(new URLSearchParams(body)); +} + +function sendHtml(res, html) { + setNoCacheHeaders(res); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(html); +} + +function sendJson(res, payload) { + setNoCacheHeaders(res); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify(payload)); +} + +function sendJsonText(res, payloadText) { + setNoCacheHeaders(res); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(payloadText); +} + +function sendXml(res, payload) { + setNoCacheHeaders(res); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/xml; charset=utf-8'); + res.end(payload); +} + +function sendText(res, payload) { + setNoCacheHeaders(res); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end(payload); +} + +function sendNotFound(res) { + setNoCacheHeaders(res); + res.statusCode = 404; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end('Not Found'); +} + +function setNoCacheHeaders(res) { + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); +} + +function setStaticCacheHeaders(res, searchParams) { + if(searchParams.has('DX_HTTP_CACHE')) { + res.setHeader('Cache-Control', 'public, max-age=31536000'); + } else { + res.setHeader('Cache-Control', 'private, must-revalidate, max-age=0'); + } +} + +function getCacheBuster(searchParams) { + if(searchParams.has('DX_HTTP_CACHE')) { + return `DX_HTTP_CACHE=${searchParams.get('DX_HTTP_CACHE')}`; + } + + return ''; +} + +function contentWithCacheBuster(contentPath, cacheBuster) { + if(!cacheBuster) { + return contentPath; + } + + return `${contentPath}${contentPath.includes('?') ? '&' : '?'}${cacheBuster}`; +} + +function getContentType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + + switch(ext) { + case '.html': + case '.htm': + return 'text/html; charset=utf-8'; + case '.css': + return 'text/css; charset=utf-8'; + case '.js': + case '.mjs': + return 'application/javascript; charset=utf-8'; + case '.json': + return 'application/json; charset=utf-8'; + case '.xml': + case '.xsl': + return 'text/xml; charset=utf-8'; + case '.txt': + case '.md': + case '.log': + return 'text/plain; charset=utf-8'; + case '.svg': + return 'image/svg+xml'; + case '.png': + return 'image/png'; + case '.jpg': + case '.jpeg': + return 'image/jpeg'; + case '.gif': + return 'image/gif'; + case '.ico': + return 'image/x-icon'; + case '.woff': + return 'font/woff'; + case '.woff2': + return 'font/woff2'; + case '.ttf': + return 'font/ttf'; + case '.eot': + return 'application/vnd.ms-fontobject'; + case '.map': + return 'application/json; charset=utf-8'; + case '.wasm': + return 'application/wasm'; + default: + return 'application/octet-stream'; + } +} + +function jsonString(value) { + return JSON.stringify(value); +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function escapeXmlText(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>'); +} + +function escapeXmlAttr(value) { + return escapeXmlText(value) + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function loadPorts(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function safeReadFile(filePath) { + try { + return fs.readFileSync(filePath, 'utf8'); + } catch(_) { + return ''; + } +} + +function parseBoolean(value) { + return String(value).toLowerCase() === 'true'; +} + +function parseNumber(value) { + const number = Number(value); + return Number.isNaN(number) ? 0 : number; +} + +function splitCommaList(value) { + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function safeDecodeURIComponent(value) { + try { + return decodeURIComponent(value); + } catch(_) { + return value; + } +} + +function writeLastSuiteTime() { + fs.writeFileSync(LAST_SUITE_TIME_FILENAME, formatDateForSuiteTimestamp(new Date()), 'utf8'); +} + +function formatDateForSuiteTimestamp(date) { + return [ + date.getFullYear(), + pad2(date.getMonth() + 1), + pad2(date.getDate()), + ].join('-') + 'T' + [ + pad2(date.getHours()), + pad2(date.getMinutes()), + pad2(date.getSeconds()), + ].join(':'); +} + +function isContinuousIntegration() { + return Boolean(process.env.CCNetWorkingDirectory || process.env.DEVEXTREME_TEST_CI); +} + +function resolveNodePath() { + if(process.env.CCNetWorkingDirectory) { + const customPath = path.join(process.env.CCNetWorkingDirectory, 'node', 'node.exe'); + if(fs.existsSync(customPath)) { + return customPath; + } + } + + return 'node'; +} + +function logMiscErrorCore(data) { + if(!RUN_FLAGS.isContinuousIntegration) { + return; + } + + try { + fs.appendFileSync(MISC_ERRORS_FILENAME, `${data}${os.EOL}`, 'utf8'); + } catch(_) { + // Ignore logging errors. + } +} + +function createRawLogger(filePath) { + return { + filePath, + writeLine(text = '') { + this.write(`${text || ''}\r\n`); + this._time = true; + }, + write(text = '') { + if(!text) { + return; + } + + if(this._time !== false) { + this._time = false; + fs.appendFileSync(this.filePath, `${formatLogTime(new Date())} `, 'utf8'); + } + + fs.appendFileSync(this.filePath, text, 'utf8'); + }, + _time: true, + }; +} + +function formatLogTime(date) { + let hours = date.getHours() % 12; + if(hours === 0) { + hours = 12; + } + + return `${pad2(hours)}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`; +} + +function write(message, color) { + const text = String(message || ''); + logger.write(text); + process.stdout.write(colorize(text, color)); +} + +function writeLine(message = '', color) { + const text = String(message || ''); + logger.writeLine(text); + process.stdout.write(`${colorize(text, color)}\n`); +} + +function writeError(message) { + const text = `ERROR: ${message}`; + logger.writeLine(text); + process.stderr.write(`${text}\n`); +} + +function colorize(text, color) { + if(!color) { + return text; + } + + const colorCodes = { + red: 31, + green: 32, + yellow: 33, + white: 37, + }; + + const code = colorCodes[color]; + if(!code) { + return text; + } + + return `\u001b[${code}m${text}\u001b[0m`; +} + +function pad2(value) { + return String(value).padStart(2, '0'); +} + +function httpGetText(targetUrl) { + return new Promise((resolve, reject) => { + const request = http.get(targetUrl, (response) => { + const chunks = []; + + response.on('data', (chunk) => { + chunks.push(chunk); + }); + + response.on('end', () => { + resolve(Buffer.concat(chunks).toString('utf8')); + }); + }); + + request.on('error', reject); + }); +} From e149cdc95dda1104e7b995c1d369c2e658e55edf Mon Sep 17 00:00:00 2001 From: Eugen Zha Date: Wed, 4 Mar 2026 19:13:39 +0200 Subject: [PATCH 02/11] Remove obsolete testing runner files and views --- .github/actions/run-qunit-tests/action.yml | 5 - .github/workflows/qunit_tests.yml | 2 - packages/devextreme/build/build-dotnet.sln | 24 - packages/devextreme/project.json | 6 - .../testing/runner/.vscode/launch.json | 14 - .../testing/runner/.vscode/tasks.json | 13 - .../runner/Controllers/MainController.cs | 316 ---------- .../TestVectorMapDataController.cs | 249 -------- .../Controllers/ThemesTestController.cs | 31 - .../testing/runner/Models/BaseRunViewModel.cs | 15 - .../runner/Models/Results/ResultItem.cs | 10 - .../testing/runner/Models/Results/TestCase.cs | 29 - .../runner/Models/Results/TestResults.cs | 77 --- .../runner/Models/Results/TestSuite.cs | 20 - .../testing/runner/Models/RunAllViewModel.cs | 13 - .../runner/Models/RunSuiteViewModel.cs | 9 - .../testing/runner/Models/UI/Category.cs | 10 - .../testing/runner/Models/UI/Suite.cs | 9 - packages/devextreme/testing/runner/Program.cs | 127 ---- .../testing/runner/Tools/ConsoleHelper.cs | 95 --- .../testing/runner/Tools/ExtensionMethods.cs | 105 ---- .../devextreme/testing/runner/Tools/Ports.cs | 23 - .../testing/runner/Tools/RunFlags.cs | 8 - .../testing/runner/Tools/UIModelHelper.cs | 129 ---- .../runner/Tools/ViewLocationExpander.cs | 18 - .../testing/runner/Views/Main/Index.cshtml | 249 -------- .../testing/runner/Views/Main/RunAll.cshtml | 564 ------------------ .../testing/runner/Views/Main/RunSuite.cshtml | 335 ----------- .../testing/runner/Views/_ViewImports.cshtml | 2 - .../devextreme/testing/runner/runner.csproj | 19 - 30 files changed, 2526 deletions(-) delete mode 100644 packages/devextreme/build/build-dotnet.sln delete mode 100644 packages/devextreme/testing/runner/.vscode/launch.json delete mode 100644 packages/devextreme/testing/runner/.vscode/tasks.json delete mode 100644 packages/devextreme/testing/runner/Controllers/MainController.cs delete mode 100644 packages/devextreme/testing/runner/Controllers/TestVectorMapDataController.cs delete mode 100644 packages/devextreme/testing/runner/Controllers/ThemesTestController.cs delete mode 100644 packages/devextreme/testing/runner/Models/BaseRunViewModel.cs delete mode 100644 packages/devextreme/testing/runner/Models/Results/ResultItem.cs delete mode 100644 packages/devextreme/testing/runner/Models/Results/TestCase.cs delete mode 100644 packages/devextreme/testing/runner/Models/Results/TestResults.cs delete mode 100644 packages/devextreme/testing/runner/Models/Results/TestSuite.cs delete mode 100644 packages/devextreme/testing/runner/Models/RunAllViewModel.cs delete mode 100644 packages/devextreme/testing/runner/Models/RunSuiteViewModel.cs delete mode 100644 packages/devextreme/testing/runner/Models/UI/Category.cs delete mode 100644 packages/devextreme/testing/runner/Models/UI/Suite.cs delete mode 100644 packages/devextreme/testing/runner/Program.cs delete mode 100644 packages/devextreme/testing/runner/Tools/ConsoleHelper.cs delete mode 100644 packages/devextreme/testing/runner/Tools/ExtensionMethods.cs delete mode 100644 packages/devextreme/testing/runner/Tools/Ports.cs delete mode 100644 packages/devextreme/testing/runner/Tools/RunFlags.cs delete mode 100644 packages/devextreme/testing/runner/Tools/UIModelHelper.cs delete mode 100644 packages/devextreme/testing/runner/Tools/ViewLocationExpander.cs delete mode 100644 packages/devextreme/testing/runner/Views/Main/Index.cshtml delete mode 100644 packages/devextreme/testing/runner/Views/Main/RunAll.cshtml delete mode 100644 packages/devextreme/testing/runner/Views/Main/RunSuite.cshtml delete mode 100644 packages/devextreme/testing/runner/Views/_ViewImports.cshtml delete mode 100644 packages/devextreme/testing/runner/runner.csproj diff --git a/.github/actions/run-qunit-tests/action.yml b/.github/actions/run-qunit-tests/action.yml index 26fb42fe954b..fbd4fefb2a9d 100644 --- a/.github/actions/run-qunit-tests/action.yml +++ b/.github/actions/run-qunit-tests/action.yml @@ -85,11 +85,6 @@ runs: shell: bash run: pnpm install --frozen-lockfile - - name: Build dotnet - working-directory: ./packages/devextreme - shell: bash - run: dotnet build build/build-dotnet.sln - - name: Run QUnit tests working-directory: ./packages/devextreme shell: bash diff --git a/.github/workflows/qunit_tests.yml b/.github/workflows/qunit_tests.yml index bf40368ac109..33bec4e20040 100644 --- a/.github/workflows/qunit_tests.yml +++ b/.github/workflows/qunit_tests.yml @@ -66,8 +66,6 @@ jobs: shell: bash env: DEVEXTREME_TEST_CI: "true" - DOTNET_CLI_TELEMETRY_OPTOUT: "true" - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "true" run: pnpx nx build:systemjs - name: Zip artifacts diff --git a/packages/devextreme/build/build-dotnet.sln b/packages/devextreme/build/build-dotnet.sln deleted file mode 100644 index bebbdfbbb43c..000000000000 --- a/packages/devextreme/build/build-dotnet.sln +++ /dev/null @@ -1,24 +0,0 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26730.8 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "runner", "../testing/runner/runner.csproj", "{DE827F82-8E95-4080-B350-3654A63AEC83}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {DE827F82-8E95-4080-B350-3654A63AEC83}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DE827F82-8E95-4080-B350-3654A63AEC83}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DE827F82-8E95-4080-B350-3654A63AEC83}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DE827F82-8E95-4080-B350-3654A63AEC83}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {CC2A0A79-649B-4163-82FA-D381F307DC12} - EndGlobalSection -EndGlobal diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index 725a27591dd2..5a8478166f84 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -792,12 +792,6 @@ { "env": "DEVEXTREME_TEST_CI" }, - { - "env": "DOTNET_CLI_TELEMETRY_OPTOUT" - }, - { - "env": "DOTNET_SKIP_FIRST_TIME_EXPERIENCE" - }, "default", "test" ], diff --git a/packages/devextreme/testing/runner/.vscode/launch.json b/packages/devextreme/testing/runner/.vscode/launch.json deleted file mode 100644 index 72614cfef66f..000000000000 --- a/packages/devextreme/testing/runner/.vscode/launch.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "configurations": [ - { - "name": "Debug", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "Build Test Runner", - "cwd": "${workspaceFolder}", - "program": "bin/runner.dll", - "stopAtEntry": false, - "console": "integratedTerminal" - } - ] -} diff --git a/packages/devextreme/testing/runner/.vscode/tasks.json b/packages/devextreme/testing/runner/.vscode/tasks.json deleted file mode 100644 index ee41b4ebd52f..000000000000 --- a/packages/devextreme/testing/runner/.vscode/tasks.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": "Build Test Runner", - "type": "shell", - "command": "dotnet", - "args": [ "build" ], - "group": "build", - "problemMatcher": "$msCompile" - } - ] -} diff --git a/packages/devextreme/testing/runner/Controllers/MainController.cs b/packages/devextreme/testing/runner/Controllers/MainController.cs deleted file mode 100644 index fedc21b1aebd..000000000000 --- a/packages/devextreme/testing/runner/Controllers/MainController.cs +++ /dev/null @@ -1,316 +0,0 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Newtonsoft.Json; -using Runner.Models; -using Runner.Tools; -using System; -using System.Collections; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading; -using IOFile = System.IO.File; - -namespace Runner.Controllers -{ - [ResponseCache(Location = ResponseCacheLocation.None, NoStore = true)] - public class MainController : Controller - { - static readonly object IO_SYNC = new object(); - static readonly SemaphoreSlim ASYNC_SYNC = new SemaphoreSlim(1, 1); - readonly string _completedSuitesFileName; - - UIModelHelper _uiModelHelper; - IWebHostEnvironment _env; - RunFlags _runFlags; - - public MainController(IWebHostEnvironment env, RunFlags runFlags) - { - ConsoleHelper.Logger.SetWorkingFolder(env.ContentRootPath); - _env = env; - _runFlags = runFlags; - - _completedSuitesFileName = Path.Combine(_env.ContentRootPath, "testing/CompletedSuites.txt"); - } - - protected UIModelHelper UIModelHelper - { - get - { - if (_uiModelHelper == null) - _uiModelHelper = new UIModelHelper(ActionContext, _env); - return _uiModelHelper; - } - } - - [ActionContext] - public ActionContext ActionContext { get; set; } - - - public IActionResult Index() - { - return View(); - } - - public object CategoriesJson() - { - return UIModelHelper.ReadCategories(); - } - - public object SuitesJson(string id) - { - return UIModelHelper.ReadSuites(id); - } - - public IActionResult RunSuite(string catName, string suiteName, string frame) - { - var model = new RunSuiteViewModel - { - Title = suiteName, - ScriptVirtualPath = UIModelHelper.GetSuiteVirtualPath(catName, suiteName), - }; - - AssignBaseRunProps(model); - - return View(model); - } - - public IActionResult RunAll(string constellation, string include, string exclude) - { - HashSet includeSet = null, excludeSet = null, excludeSuites = null; - int partIndex = 0; - int partCount = 1; - - if (!String.IsNullOrEmpty(include)) - includeSet = new HashSet(include.Split(',')); - if (!String.IsNullOrEmpty(exclude)) - excludeSet = new HashSet(exclude.Split(',')); - if (!String.IsNullOrEmpty(constellation) && constellation.Contains('(') && constellation.EndsWith(')')) { - var constellationParts = constellation.TrimEnd(')').Split('('); - var parts = constellationParts[1].Split('/'); - - constellation = constellationParts[0]; - partIndex = Int32.Parse(parts[0]) - 1; - partCount = Int32.Parse(parts[1]); - } - - var packageJson = IOFile.ReadAllText(Path.Combine(_env.ContentRootPath, "package.json")); - - if (_runFlags.IsContinuousIntegration) { - if (IOFile.Exists(_completedSuitesFileName)) { - var completedSuites = IOFile.ReadAllLines(_completedSuitesFileName); - excludeSuites = new HashSet(completedSuites); - } - } - - var model = new RunAllViewModel - { - Constellation = constellation ?? "", - CategoriesList = include, - Version = JsonConvert.DeserializeObject(packageJson)["version"].ToString(), - Suites = UIModelHelper.GetAllSuites(HasDeviceModeFlag(), constellation, includeSet, excludeSet, excludeSuites, partIndex, partCount) - }; - - AssignBaseRunProps(model); - - return View(model); - } - - [HttpPost] - public void NotifyTestStarted(string name) { - lock (IO_SYNC) { - ConsoleHelper.Logger.WriteLine($" [ run] {name}"); - } - } - [HttpPost] - public void NotifyTestCompleted(string name, bool passed) { - lock (IO_SYNC) { - ConsoleHelper.Logger.WriteLine($" [{(passed ? " ok" : "fail")}] {name}"); - } - } - [HttpPost] - public async System.Threading.Tasks.Task NotifySuiteFinalized(string name, bool passed, int runtime) - { - Response.ContentType = "text/plain"; - - var threadId = System.Threading.Thread.CurrentThread.ManagedThreadId; - - try - { - await ASYNC_SYNC.WaitAsync(); - try - { - if (passed && _runFlags.IsContinuousIntegration) - { - IOFile.AppendAllLines(_completedSuitesFileName, new[] { name }); - } - - if (_runFlags.IsContinuousIntegration) - { - var timestamp = DateTime.Now.ToString("s"); - var filePath = Path.Combine(_env.ContentRootPath, "testing/LastSuiteTime.txt"); - - IOFile.WriteAllText(filePath, timestamp); - } - } - finally - { - ASYNC_SYNC.Release(); - } - - ConsoleHelper.Write("["); - if (passed) - ConsoleHelper.Write(" OK ", ConsoleColor.Green); - else - ConsoleHelper.Write("FAIL", ConsoleColor.Red); - - TimeSpan runSpan = TimeSpan.FromMilliseconds(runtime); - ConsoleHelper.WriteLine($"] {name} in {Math.Round(runSpan.TotalSeconds, 3)}s"); - - await Response.WriteAsync("OK"); - await Response.Body.FlushAsync(); - } - catch (Exception) { } - } - - [HttpPost] - public async System.Threading.Tasks.Task NotifyIsAlive() - { - Response.ContentType = "text/plain"; - - if (_runFlags.IsContinuousIntegration) - { - try - { - var timestamp = DateTime.Now.ToString("s"); - - await ASYNC_SYNC.WaitAsync(); - try - { - var filePath = Path.Combine(_env.ContentRootPath, "testing/LastSuiteTime.txt"); - IOFile.WriteAllText(filePath, timestamp); - } - finally - { - ASYNC_SYNC.Release(); - } - - await Response.WriteAsync("OK"); - await Response.Body.FlushAsync(); - } - catch (Exception) { } - } - else - { - await Response.WriteAsync("OK"); - } - } - - [HttpPost] - public void SaveResults() - { - var singleRun = _runFlags.SingleRun; - var hasFailure = false; - var xml = ""; - - Response.ContentType = "text/plain"; - try - { - var json = new StreamReader(Request.Body).ReadToEnd(); - ValidateResultsJson(json); - - var results = Runner.Models.Results.TestResults.LoadFromJson(json); - hasFailure = results.failures > 0; - xml = results.ToXmlText(); - - if (singleRun) - { - ConsoleHelper.WriteLine(); - results.PrintTextReport(); - } - } - catch (Exception x) - { - LogMiscErrorCore("Failed to save results. " + x); - hasFailure = true; - } - - IOFile.WriteAllText(ResultXmlPath(), xml); - - if (singleRun) - { - Environment.Exit(hasFailure ? 1 : 0); - } - } - - public ContentResult DisplayResults() - { - var xslUrl = Url.Content("~/packages/devextreme/testing/content/unittests.xsl"); - var xml = new StringBuilder(); - xml.AppendLine(""); - xml.AppendLine(""); - xml.AppendLine(""); - xml.Append(IOFile.ReadAllText(ResultXmlPath())); - xml.AppendLine(""); - - return Content(xml.ToString(), "text/xml"); - } - - [HttpPost] - public void LogMiscError() - { - Response.ContentType = "text/plain"; - LogMiscErrorCore(Request.Form["msg"]); - } - - void LogMiscErrorCore(string data) - { - if (_runFlags.IsContinuousIntegration) - { - lock (IO_SYNC) - { - IOFile.AppendAllText(Path.Combine(_env.ContentRootPath, "testing/MiscErrors.log"), data + Environment.NewLine); - } - } - } - - void AssignBaseRunProps(BaseRunViewModel m) - { - var q = Request.Query; - - m.IsContinuousIntegration = _runFlags.IsContinuousIntegration; - m.NoGlobals = q.ContainsKey("noglobals"); - m.NoTimers = q.ContainsKey("notimers"); - m.NoTryCatch = q.ContainsKey("notrycatch"); - m.NoJQuery = q.ContainsKey("nojquery"); - m.ShadowDom = q.ContainsKey("shadowDom"); - m.WorkerInWindow = q.ContainsKey("workerinwindow"); - m.NoCsp = q.ContainsKey("nocsp") || false; - - var maxWorkersEnv = Environment.GetEnvironmentVariable("MAX_WORKERS"); - if (!String.IsNullOrEmpty(maxWorkersEnv) && Int32.TryParse(maxWorkersEnv, out int maxWorkers)) - { - m.MaxWorkers = maxWorkers; - } - } - - bool HasDeviceModeFlag() - { - return Request.Query.ContainsKey("deviceMode"); - } - - string ResultXmlPath() - { - return Path.Combine(_env.ContentRootPath, "testing/Results.xml"); - } - - static void ValidateResultsJson(string json) - { - var zeroIndex = json.IndexOf("\\u0000"); - if (zeroIndex > -1) - throw new Exception("Result JSON has bad content: " + json.Substring(zeroIndex - 200, 400)); - } - } -} diff --git a/packages/devextreme/testing/runner/Controllers/TestVectorMapDataController.cs b/packages/devextreme/testing/runner/Controllers/TestVectorMapDataController.cs deleted file mode 100644 index dd39dcdf031b..000000000000 --- a/packages/devextreme/testing/runner/Controllers/TestVectorMapDataController.cs +++ /dev/null @@ -1,249 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Hosting; -using Newtonsoft.Json; -using System; -using System.Linq; -using System.IO; -using System.Threading; -using System.Net.Http; -using Runner.Tools; -using Directory = System.IO.Directory; -using Path = System.IO.Path; -using IOFile = System.IO.File; - -namespace Runner.Controllers -{ - [ResponseCache(Location = ResponseCacheLocation.None, NoStore = true)] - public class TestVectorMapDataController : Controller - { - private static readonly System.Text.Encoding Encoding = System.Text.Encoding.UTF8; - - private const int NodeServerCheckTimeout = 100; - private const int NodeServerKillTimeout = 200; - private const int NodeScriptTimeout = 15000; - private const int DirectoryKillTimeout = 5000; - - private const string PathToDataDirectory = "testing/content/VectorMapData/"; - - private static readonly string PathToNode; - - static readonly HttpClient HTTP = new HttpClient(); - static readonly object SYNC = new object(); - static NodeServerContext NodeServerContextInstance; - - static TestVectorMapDataController() - { - PathToNode = "node"; - - var ccnetDir = Environment.GetEnvironmentVariable("CCNetWorkingDirectory"); - if (ccnetDir != null) - { - var customPath = Path.Combine(ccnetDir, "node/node.exe"); - if (IOFile.Exists(customPath)) - PathToNode = customPath; - } - } - - IWebHostEnvironment _env; - - public TestVectorMapDataController(IWebHostEnvironment env) - { - _env = env; - ConsoleHelper.Logger.SetWorkingFolder(env.ContentRootPath); - } - - private string ReadTextFile(string path) - { - return System.IO.File.ReadAllText(path, Encoding); - } - - public object GetTestData() - { - var items = Directory.GetFiles(Path.Combine(_env.ContentRootPath, PathToDataDirectory), "*.txt").Select(name => - { - return new - { - name = Path.GetFileNameWithoutExtension(name), - expected = ReadTextFile(name) - }; - }); - return items; - } - - public IActionResult ParseBuffer(string id) - { - return RedirectRequestToNodeServer("parse-buffer", id); - } - - public IActionResult ReadAndParse(string id) - { - return RedirectRequestToNodeServer("read-and-parse", id); - } - - private sealed class NodeServerContext - { - internal readonly string pathToNode; - internal readonly AutoResetEvent waitHandle = new AutoResetEvent(false); - internal readonly string arguments; - internal int counter = 0; - internal DateTime timeout; - - internal NodeServerContext(string pathToNode, string arguments) - { - this.pathToNode = pathToNode; - this.arguments = arguments; - } - } - - private void StartNodeServer() - { - lock (SYNC) - { - if (NodeServerContextInstance == null) - { - var args = new[] { - Path.Combine(_env.ContentRootPath, "testing/helpers/vectormaputils-tester.js"), - Path.Combine(_env.ContentRootPath, PathToDataDirectory) - }; - - NodeServerContextInstance = new NodeServerContext(PathToNode, String.Join(" ", args.Select(QuoteArg))); - ThreadPool.QueueUserWorkItem(NodeServerThreadFunc); - NodeServerContextInstance.waitHandle.WaitOne(); - } - ++NodeServerContextInstance.counter; - } - } - - static string QuoteArg(string arg) - { - return '"' + arg + '"'; - } - - private void StopNodeServer() - { - lock (SYNC) - { - --NodeServerContextInstance.counter; - NodeServerContextInstance.timeout = DateTime.Now.AddMilliseconds(NodeServerKillTimeout); - } - } - - private static System.Diagnostics.Process StartProcess(string pathToNode, string arguments) - { - return System.Diagnostics.Process.Start(pathToNode, arguments); - } - - private static void NodeServerThreadFunc(object state) - { - using (var process = StartProcess(NodeServerContextInstance.pathToNode, NodeServerContextInstance.arguments)) - { - NodeServerContextInstance.waitHandle.Set(); - while (true) - { - Thread.Sleep(NodeServerCheckTimeout); - lock (SYNC) - { - if (NodeServerContextInstance.counter == 0 && DateTime.Now > NodeServerContextInstance.timeout) - { - NodeServerContextInstance = null; - process.Kill(); - return; - } - } - } - } - } - - private IActionResult RedirectRequestToNodeServer(string action, string arg) - { - StartNodeServer(); - try - { - { - var startedAt = DateTime.Now; - while (true) - { - try - { - var req = new HttpRequestMessage(HttpMethod.Get, string.Format("http://127.0.0.1:{0}/{1}/{2}", Ports.Get("vectormap-utils-tester"), action, arg)); - using (var message = HTTP.Send(req)) - using (var reader = new StreamReader(message.Content.ReadAsStream())) - { - return Content(reader.ReadToEnd(), "application/json"); - } - } - catch (Exception) - { - if (DateTime.Now - startedAt > TimeSpan.FromSeconds(5)) - throw; - } - } - } - - // request.Method = "GET"; - // request.ContentLength = 0; - // request.ContentType = "text/html"; - } - finally - { - StopNodeServer(); - } - } - - public ActionResult ExecuteConsoleApp(string arg) - { - var inputDirectory = Path.Combine(_env.ContentRootPath, PathToDataDirectory); - var outputDirectory = Path.Combine(inputDirectory, "__Output"); - var arguments = Path.GetFullPath(Path.Combine(_env.ContentRootPath, "artifacts/js/vectormap-utils/dx.vectormaputils.node.js")) + " " + inputDirectory; - if (Request.Query.ContainsKey("file")) - { - arguments += Request.Query["file"]; - } - arguments += " --quiet --output " + outputDirectory + - " --settings " + Path.Combine(inputDirectory, "_settings.js") + - " --process-file-content " + Path.Combine(inputDirectory, "_processFileContent.js"); - var isJson = Request.Query.ContainsKey("json"); - if (isJson) - { - arguments += " --json"; - } - try - { - Directory.CreateDirectory(outputDirectory); - using (var process = StartProcess(PathToNode, arguments)) - { - if (!process.WaitForExit(NodeScriptTimeout)) - { - process.Kill(); - } - } - var result = Directory.GetFiles(outputDirectory, isJson ? "*.json" : "*.js").Select(file => - { - var text = ReadTextFile(file); - var variable = (string)null; - if (!isJson) - { - var k = text.IndexOf("="); - if (k > 0) - { - variable = text.Substring(0, k).Trim(); - text = text.Substring(k + 1, text.Length - k - 2).Trim(); - } - } - return new - { - file = Path.GetFileNameWithoutExtension(file) + Path.GetExtension(file), - variable = variable, - content = JsonConvert.DeserializeObject(text) - }; - }).ToArray(); - return Content(JsonConvert.SerializeObject(result), "application/json"); - } - finally - { - Directory.Delete(outputDirectory, true); - } - } - - } -} diff --git a/packages/devextreme/testing/runner/Controllers/ThemesTestController.cs b/packages/devextreme/testing/runner/Controllers/ThemesTestController.cs deleted file mode 100644 index c12b788225ac..000000000000 --- a/packages/devextreme/testing/runner/Controllers/ThemesTestController.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Hosting; -using System.Linq; -using Runner.Tools; -using Directory = System.IO.Directory; -using Path = System.IO.Path; - -namespace Runner.Controllers -{ - [Route("themes-test")] - [ResponseCache(Location = ResponseCacheLocation.None, NoStore = true)] - public class ThemesTestController : Controller { - string _bundlesPath; - public ThemesTestController(IWebHostEnvironment env) - { - ConsoleHelper.Logger.SetWorkingFolder(env.ContentRootPath); - _bundlesPath = Path.Combine(env.ContentRootPath, "scss", "bundles"); - } - - [Route("get-css-files-list")] - public IActionResult GetCssFilesList() { - var fileNames = from bundleDirectory - in Directory.EnumerateDirectories(_bundlesPath) - from fullFilename - in Directory.EnumerateFiles(bundleDirectory, "*.scss") - select Path.GetFileNameWithoutExtension(fullFilename) + ".css"; - - return Json(fileNames); - } - } -} diff --git a/packages/devextreme/testing/runner/Models/BaseRunViewModel.cs b/packages/devextreme/testing/runner/Models/BaseRunViewModel.cs deleted file mode 100644 index a03e3665f843..000000000000 --- a/packages/devextreme/testing/runner/Models/BaseRunViewModel.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Runner.Models -{ - public abstract class BaseRunViewModel - { - public bool NoTryCatch { get; set; } - public bool NoGlobals { get; set; } - public bool NoTimers { get; set; } - public bool NoJQuery { get; set; } - public bool ShadowDom { get; set; } - public bool NoCsp { get; set; } - public bool WorkerInWindow { get; set; } - public bool IsContinuousIntegration { get; set; } - public int? MaxWorkers { get; set; } - } -} diff --git a/packages/devextreme/testing/runner/Models/Results/ResultItem.cs b/packages/devextreme/testing/runner/Models/Results/ResultItem.cs deleted file mode 100644 index ed9800000389..000000000000 --- a/packages/devextreme/testing/runner/Models/Results/ResultItem.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Xml.Serialization; - -namespace Runner.Models.Results -{ - public abstract class ResultItem - { - [XmlAttribute] - public string name; - } -} diff --git a/packages/devextreme/testing/runner/Models/Results/TestCase.cs b/packages/devextreme/testing/runner/Models/Results/TestCase.cs deleted file mode 100644 index 3fd7565629aa..000000000000 --- a/packages/devextreme/testing/runner/Models/Results/TestCase.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Xml.Serialization; - -namespace Runner.Models.Results -{ - public class TestCase : ResultItem - { - public class MessageContainer - { - public string message; - } - - [XmlAttribute] - public string url; - - [XmlAttribute] - public bool executed = true; - - [XmlAttribute] - public string time; - - [XmlIgnore] - public bool executedSpecified { get { return !executed; } } - - public MessageContainer failure; - - public MessageContainer reason; - } - -} diff --git a/packages/devextreme/testing/runner/Models/Results/TestResults.cs b/packages/devextreme/testing/runner/Models/Results/TestResults.cs deleted file mode 100644 index a3206cd7a21c..000000000000 --- a/packages/devextreme/testing/runner/Models/Results/TestResults.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Xml.Serialization; -using System.Text; -using System.Xml; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using System.Reflection; - -namespace Runner.Models.Results -{ - [XmlRoot("test-results")] - public class TestResults - { - [XmlAttribute] - public string name; - - [XmlAttribute] - public int total; - - [XmlAttribute] - public int failures; - - [XmlElement("test-suite", typeof(TestSuite))] - public List suites; - - public string ToXmlText() - { - var ns = new XmlSerializerNamespaces(); - ns.Add("", ""); - - var builder = new StringBuilder(); - using (var wr = XmlWriter.Create(builder, new XmlWriterSettings { OmitXmlDeclaration = true, Indent = true })) - { - new XmlSerializer(typeof(TestResults)).Serialize(wr, this, ns); - } - return builder.ToString(); - } - - class ResultItemConverter : JsonConverter - { - public override bool CanConvert(Type objectType) - { - return typeof(ResultItem).IsAssignableFrom(objectType); - } - - public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) - { - var raw = JObject.Load(reader); - var result = InstantiateItem((string)raw["__type"]); - serializer.Populate(raw.CreateReader(), result); - return result; - } - - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) - { - throw new NotImplementedException(); - } - - static object InstantiateItem(string id) - { - if (id == "case") - return new TestCase(); - if (id == "suite") - return new TestSuite(); - - throw new NotImplementedException(); - } - } - - public static TestResults LoadFromJson(string json) - { - return JsonConvert.DeserializeObject(json, new ResultItemConverter()); - } - } - -} diff --git a/packages/devextreme/testing/runner/Models/Results/TestSuite.cs b/packages/devextreme/testing/runner/Models/Results/TestSuite.cs deleted file mode 100644 index c9c4278bbcd9..000000000000 --- a/packages/devextreme/testing/runner/Models/Results/TestSuite.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Collections.Generic; -using System.Xml.Serialization; - -namespace Runner.Models.Results -{ - public class TestSuite : ResultItem - { - [XmlAttribute] - public double time; - - [XmlAttribute("pure-time")] - public double pureTime; - - [XmlArray("results")] - [XmlArrayItem("test-case", typeof(TestCase))] - [XmlArrayItem("test-suite", typeof(TestSuite))] - public List results = new List(); - } - -} diff --git a/packages/devextreme/testing/runner/Models/RunAllViewModel.cs b/packages/devextreme/testing/runner/Models/RunAllViewModel.cs deleted file mode 100644 index 4c43dc2c11a8..000000000000 --- a/packages/devextreme/testing/runner/Models/RunAllViewModel.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Collections.Generic; -using Runner.Models.UI; - -namespace Runner.Models -{ - public class RunAllViewModel : BaseRunViewModel - { - public string Constellation { get; set; } - public string CategoriesList { get; set; } - public string Version { get; set; } - public IEnumerable Suites { get; set; } - } -} diff --git a/packages/devextreme/testing/runner/Models/RunSuiteViewModel.cs b/packages/devextreme/testing/runner/Models/RunSuiteViewModel.cs deleted file mode 100644 index 38f5cc694fbc..000000000000 --- a/packages/devextreme/testing/runner/Models/RunSuiteViewModel.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Runner.Models -{ - public class RunSuiteViewModel : BaseRunViewModel - { - public string ScriptVirtualPath { get; set; } - public string Title { get; set; } - - } -} diff --git a/packages/devextreme/testing/runner/Models/UI/Category.cs b/packages/devextreme/testing/runner/Models/UI/Category.cs deleted file mode 100644 index 21c91bd4232a..000000000000 --- a/packages/devextreme/testing/runner/Models/UI/Category.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Runner.Models.UI -{ - public class Category - { - public string Name; - public bool Explicit; - public string Constellation; - public bool RunOnDevices; - } -} diff --git a/packages/devextreme/testing/runner/Models/UI/Suite.cs b/packages/devextreme/testing/runner/Models/UI/Suite.cs deleted file mode 100644 index a719d1c25889..000000000000 --- a/packages/devextreme/testing/runner/Models/UI/Suite.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Runner.Models.UI -{ - public class Suite - { - public string ShortName; - public string FullName; - public string Url; - } -} diff --git a/packages/devextreme/testing/runner/Program.cs b/packages/devextreme/testing/runner/Program.cs deleted file mode 100644 index 08b54353a9f2..000000000000 --- a/packages/devextreme/testing/runner/Program.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Net; -using System.Threading; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Razor; -using Microsoft.AspNetCore.StaticFiles; -using Microsoft.AspNetCore.Server.Kestrel.Core; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.FileProviders; -using Newtonsoft.Json.Serialization; -using Runner.Tools; - -namespace Runner -{ - public class Program - { - public static int Main(string[] argv) - { - ServicePointManager.DefaultConnectionLimit = 1000; - ServicePointManager.MaxServicePointIdleTime = 10000; - ServicePointManager.Expect100Continue = false; - ServicePointManager.UseNagleAlgorithm = false; - ServicePointManager.ReusePort = true; - - ThreadPool.SetMinThreads(100, 100); - ThreadPool.SetMaxThreads(1000, 1000); - - try - { - var rootPath = Path.Combine(AppContext.BaseDirectory, "../../.."); - ConsoleHelper.Logger.SetWorkingFolder(rootPath); - ConsoleHelper.Logger.Write(); - Ports.Load(Path.Combine(rootPath, "ports.json")); - - var url = "http://0.0.0.0:" + Ports.Get("qunit"); - - var builder = new WebHostBuilder() - .UseUrls(url) - .UseKestrel() - .UseContentRoot(rootPath) - .ConfigureServices(services => - { - services - .AddMvcCore() - .AddViews() - .AddRazorViewEngine() - .AddNewtonsoftJson(options => options.SerializerSettings.ContractResolver = new DefaultContractResolver()); - services.AddMvc(options => options.EnableEndpointRouting = false).AddRazorRuntimeCompilation(); - services.AddWebEncoders(); - - services.Configure(options => options.ViewLocationExpanders.Add(new ViewLocationExpander())); - services.Configure(options => - { - options.AllowSynchronousIO = true; - }); - - services.AddSingleton(new RunFlags - { - SingleRun = argv.Contains("--single-run"), - IsContinuousIntegration = IsContinuousIntegration() - }); - }) - .Configure(app => app - .UseStatusCodePages() - .UseDeveloperExceptionPage() - .UseMvc(routes => routes - .MapRoute("RunSuite", "run/{catName}/{suiteName}", new { controller = "Main", action = "RunSuite" }, new { suiteName = @".*\.js" }) - .MapRoute("RunAll", "run", new { controller = "Main", action = "RunAll" }) - .MapRoute("default", "{controller=Main}/{action=Index}/{id?}") - ) - .UseFileServer(new FileServerOptions - { - EnableDirectoryBrowsing = true, - EnableDefaultFiles = false, - StaticFileOptions = { - FileProvider = new PhysicalFileProvider(Path.Combine(rootPath, "../..")), - ServeUnknownFileTypes = true, - OnPrepareResponse = OnPrepareStaticFileResponse - } - }) - ); - - using (var host = builder.Build()) - { - host.Start(); - ConsoleHelper.WriteLine($"QUnit runner server listens on {url}..."); - Thread.Sleep(Timeout.Infinite); - } - - return 0; - } - catch (Exception x) - { - ConsoleHelper.Error.WriteLine(x.Message); - return 1; - } - } - - static void OnPrepareStaticFileResponse(StaticFileResponseContext staticFileContext) - { - var context = staticFileContext.Context; - var req = context.Request; - var res = context.Response; - var headers = res.Headers; - - if (req.Query.ContainsKey("DX_HTTP_CACHE")) - { - headers["Cache-Control"] = "public, max-age=31536000"; - } - else - { - headers["Cache-Control"] = "private, must-revalidate, max-age=0"; - } - - headers.Remove("ETag"); - } - - static bool IsContinuousIntegration() - { - return !String.IsNullOrEmpty(Environment.GetEnvironmentVariable("CCNetWorkingDirectory")) - || !String.IsNullOrEmpty(Environment.GetEnvironmentVariable("DEVEXTREME_TEST_CI")); - } - } -} diff --git a/packages/devextreme/testing/runner/Tools/ConsoleHelper.cs b/packages/devextreme/testing/runner/Tools/ConsoleHelper.cs deleted file mode 100644 index 8ef7fde73dd8..000000000000 --- a/packages/devextreme/testing/runner/Tools/ConsoleHelper.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System; -using System.IO; - -namespace Runner.Tools -{ - public class ConsoleWriter { - readonly TextWriter target; - readonly string header; - - internal ConsoleWriter(TextWriter target, string header = "") { - this.target = target; - this.header = header; - } - - void WriteCore(string message, ConsoleColor? foreground, bool line) { - if (foreground.HasValue) { - Console.ForegroundColor = foreground.Value; - } - - if (!String.IsNullOrEmpty(message) || line) { - var msg = $"{this.header}{message}"; - if (line) { - ConsoleHelper.Logger.WriteLine(msg); - target.WriteLine(msg); - } else { - ConsoleHelper.Logger.Write(msg); - target.Write(msg); - } - } - - if (foreground.HasValue) { - Console.ResetColor(); - } - } - public void Write(string message, ConsoleColor? foreground = null) { - WriteCore(message, foreground, false); - } - - public void WriteLine() { - WriteCore(null, null, true); - } - - public void WriteLine(string message, ConsoleColor? foreground = null) { - WriteCore(message, foreground, true); - } - } - - public class Logger { - readonly string fileName; - bool time = true; - public static readonly object olock = new object(); - string path; - - public Logger(string fileName) { this.fileName = fileName; } - - public void SetWorkingFolder(string path) { - lock (olock) { - this.path = Path.Combine(path, this.fileName); - } - } - - void LogCore(string text) { - File.AppendAllText(this.path, text); - } - - public void Write(string text = "") { - lock (olock) { - if (String.IsNullOrEmpty(text)) - return; - if (this.time) { - LogCore($"{DateTime.Now:hh:mm:ss} "); - this.time = false; - } - - LogCore(text); - } - } - - public void WriteLine(string text = "") { - Write($"{text ?? ""}\r\n"); - this.time = true; - } - } - public static class ConsoleHelper { - public static readonly Logger Logger = new Logger("testing/RawLog.txt"); - public static readonly ConsoleWriter Out = new ConsoleWriter(Console.Out); - public static readonly ConsoleWriter Error = new ConsoleWriter(Console.Error, "ERROR: "); - - public static void Write(string message, ConsoleColor? foreground = null) { Out.Write(message, foreground);} - public static void WriteLine() { Out.WriteLine(); } - public static void WriteLine(string message, ConsoleColor? foreground = null) { - Out.WriteLine(message, foreground); - } - } -} diff --git a/packages/devextreme/testing/runner/Tools/ExtensionMethods.cs b/packages/devextreme/testing/runner/Tools/ExtensionMethods.cs deleted file mode 100644 index 4f7eeedf653f..000000000000 --- a/packages/devextreme/testing/runner/Tools/ExtensionMethods.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.AspNetCore.Html; -using Microsoft.AspNetCore.Mvc; -using Runner.Models.Results; - -namespace Runner.Tools -{ - public static class ExtensionMethods - { - public static IHtmlContent ContentWithCacheBuster(this IUrlHelper url, string contentPath) - { - var cacheBuster = CacheBuster(url).ToString(); - var result = url.Content(contentPath).ToString(); - - if (!String.IsNullOrEmpty(cacheBuster)) - result += (result.Contains("?") ? "&" : "?") + cacheBuster; - - return new HtmlString(result); - } - - public static IHtmlContent CacheBuster(this IUrlHelper url) - { - var key = "DX_HTTP_CACHE"; - var value = url.ActionContext.HttpContext.Request.Query[key]; - var result = ""; - - if (!String.IsNullOrEmpty(value)) - result += key + "=" + value; - - return new HtmlString(result); - } - - public static IEnumerable EnumerateAllCases(this TestSuite suite) - { - foreach (var item in suite.results) - { - var innerSuite = item as TestSuite; - - if (innerSuite != null) - { - foreach (var innerTest in innerSuite.EnumerateAllCases()) - yield return innerTest; - } - else - { - yield return item as TestCase; - } - } - } - - public static void PrintTextReport(this TestResults results) - { - const int maxWrittenFailures = 50; - - var notRunCases = (from s in results.suites - from test in s.EnumerateAllCases().Where(c => c.reason != null) - select test).ToArray(); - - var writtenFailures = 0; - var separator = "".PadLeft(80, '-'); - - ConsoleHelper.WriteLine($"Tests run: {results.total}, Failures: {results.failures}, Not run: {notRunCases.Length}", - results.failures > 0 ? ConsoleColor.Red : notRunCases.Length > 0 ? ConsoleColor.Yellow : ConsoleColor.Green); - - if (notRunCases.Length > 0 && results.failures == 0) - { - foreach (var @case in notRunCases) - { - ConsoleHelper.WriteLine(separator); - ConsoleHelper.WriteLine("Skipped: " + @case.name); - ConsoleHelper.WriteLine("Reason: " + @case.reason.message); - } - } - - if (results.failures > 0) - { - var failedCases = from s in results.suites - from test in s.EnumerateAllCases().Where(c => c.failure != null) - select test; - - foreach (var @case in failedCases) - { - ConsoleHelper.WriteLine(separator); - - ConsoleHelper.WriteLine(@case.name, ConsoleColor.White); - - ConsoleHelper.WriteLine(); - ConsoleHelper.WriteLine(@case.failure.message); - - writtenFailures++; - - if (writtenFailures >= maxWrittenFailures) - { - ConsoleHelper.WriteLine($"WARNING: only first {maxWrittenFailures} failures are shown."); - break; - } - } - } - } - - } - -} diff --git a/packages/devextreme/testing/runner/Tools/Ports.cs b/packages/devextreme/testing/runner/Tools/Ports.cs deleted file mode 100644 index 94e19c0cf1b0..000000000000 --- a/packages/devextreme/testing/runner/Tools/Ports.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Newtonsoft.Json; -using System.Collections.Generic; -using System.IO; - -namespace Runner -{ - static class Ports - { - static IDictionary _dict; - - public static void Load(string path) - { - var json = File.ReadAllText(path); - _dict = JsonConvert.DeserializeObject>(json); - } - - public static int Get(string key) - { - return _dict[key]; - } - - } -} diff --git a/packages/devextreme/testing/runner/Tools/RunFlags.cs b/packages/devextreme/testing/runner/Tools/RunFlags.cs deleted file mode 100644 index a533c257f483..000000000000 --- a/packages/devextreme/testing/runner/Tools/RunFlags.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Runner -{ - public class RunFlags - { - public bool SingleRun { get; set; } - public bool IsContinuousIntegration { get; set; } - } -} diff --git a/packages/devextreme/testing/runner/Tools/UIModelHelper.cs b/packages/devextreme/testing/runner/Tools/UIModelHelper.cs deleted file mode 100644 index 23149ee5bdf1..000000000000 --- a/packages/devextreme/testing/runner/Tools/UIModelHelper.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Runner.Models.UI; -using System.IO; -using System.Collections; -using Newtonsoft.Json; -using Microsoft.AspNetCore.Mvc.Routing; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Hosting; - -namespace Runner.Tools -{ - public class UIModelHelper - { - // constellation is a set of categories, they are defined in __meta.json files inside category directories - static readonly ICollection KnownConstellations = new HashSet { "export", "misc", "ui", "ui.widgets", "ui.editors", "ui.grid", "ui.scheduler" }; - - UrlHelper UrlHelper; - string TestsRootPath; - - public UIModelHelper(ActionContext actionContext, IWebHostEnvironment env) - { - UrlHelper = new UrlHelper(actionContext); - TestsRootPath = Path.Combine(env.ContentRootPath, "testing/tests"); - } - - public IEnumerable ReadCategories() - { - return Directory.GetDirectories(TestsRootPath) - .Where(IsNotEmptyDir) - .Select(p => CategoryFromPath(p)) - .OrderBy(c => c.Name); - } - - public IEnumerable ReadSuites(string catName) - { - var catPath = Path.Combine(TestsRootPath, catName); - - foreach (var path in Directory.GetDirectories(catPath)) - { - if (!path.EndsWith("Parts")) - throw new Exception("Unexpected sub-directory in the test category: " + path); - } - - return Directory.GetFiles(catPath, "*.js") - .Select(p => SuiteFromPath(catName, p)) - .OrderBy(s => s.ShortName); - } - - public string GetSuiteVirtualPath(string catName, string suiteName) - { - return String.Format("~/packages/devextreme/testing/tests/{0}/{1}", catName, suiteName); - } - - public IEnumerable GetAllSuites(bool deviceMode, string constellation, ISet includeCategories, ISet excludeCategories, ISet excludeSuites, int partIndex, int partCount) - { - var includeCategoriesSpecified = includeCategories != null && includeCategories.Any(); - var excludeCategoriesSpecified = excludeCategories != null && excludeCategories.Any(); - - foreach (var cat in ReadCategories()) - { - if (deviceMode && !cat.RunOnDevices) - continue; - - if (!String.IsNullOrEmpty(constellation) && cat.Constellation != constellation) - continue; - - if (includeCategoriesSpecified && !includeCategories.Contains(cat.Name)) - continue; - - if (cat.Explicit) - { - if (!includeCategoriesSpecified || !includeCategories.Contains(cat.Name)) - continue; - } - - if (excludeCategoriesSpecified && excludeCategories.Contains(cat.Name)) - continue; - - int index = 0; - foreach (var suite in ReadSuites(cat.Name)) { - if(partCount <= 1 || (index % partCount) == partIndex) { - if (excludeSuites?.Contains(suite.FullName) != true) - yield return suite; - - } - index++; - } - } - } - - - Category CategoryFromPath(string path) - { - var name = Path.GetFileName(path); - var meta = JsonConvert.DeserializeObject(File.ReadAllText(Path.Combine(path, "__meta.json"))); - var constellation = (String)meta["constellation"]; - - if (!KnownConstellations.Contains(constellation)) - throw new ArgumentException("Unknown constellation (group of categories):" + constellation); - - return new Category - { - Name = name, - Constellation = constellation, - Explicit = (bool)meta["explicit"], - RunOnDevices = (bool)meta["runOnDevices"] - }; - } - - Suite SuiteFromPath(string catName, string path) - { - return new Suite - { - ShortName = Path.GetFileNameWithoutExtension(path), - FullName = catName + "/" + Path.GetFileName(path), - Url = UrlHelper.Action("RunSuite", "Main", new { catName = catName, suiteName = Path.GetFileName(path) }) - }; - } - - static bool IsNotEmptyDir(string path) - { - return Directory.EnumerateFileSystemEntries(path).Any(); - } - - } - -} diff --git a/packages/devextreme/testing/runner/Tools/ViewLocationExpander.cs b/packages/devextreme/testing/runner/Tools/ViewLocationExpander.cs deleted file mode 100644 index 02941620280c..000000000000 --- a/packages/devextreme/testing/runner/Tools/ViewLocationExpander.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Collections.Generic; -using Microsoft.AspNetCore.Mvc.Razor; - -namespace Runner.Tools -{ - public class ViewLocationExpander : IViewLocationExpander - { - public IEnumerable ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable viewLocations) - { - return new[] { "/testing/runner/Views/{1}/{0}.cshtml" }; - } - - public void PopulateValues(ViewLocationExpanderContext context) - { - context.Values["customviewlocation"] = nameof(ViewLocationExpander); - } - } -} diff --git a/packages/devextreme/testing/runner/Views/Main/Index.cshtml b/packages/devextreme/testing/runner/Views/Main/Index.cshtml deleted file mode 100644 index 6b4ba105e046..000000000000 --- a/packages/devextreme/testing/runner/Views/Main/Index.cshtml +++ /dev/null @@ -1,249 +0,0 @@ - - - - - - - - -
    -
    - Categories -
    -
    -
    - Select: - all, - none - | RUN -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    - -
      -
    • - - -
    • -
    -
    - Greyed are explicit tests -
    -
    - -
    -
    - Suites for category "" - (Refresh) -
    -
    - -
      -
    • - -
    • -
    -
    - - - - diff --git a/packages/devextreme/testing/runner/Views/Main/RunAll.cshtml b/packages/devextreme/testing/runner/Views/Main/RunAll.cshtml deleted file mode 100644 index 2b56001a926c..000000000000 --- a/packages/devextreme/testing/runner/Views/Main/RunAll.cshtml +++ /dev/null @@ -1,564 +0,0 @@ -@model RunAllViewModel - - - - QUnit All Suites test page - - - - - - - - - -
    - -
    -
    -
    -

    -

    -
    -
    - diff --git a/packages/devextreme/testing/runner/Views/Main/RunSuite.cshtml b/packages/devextreme/testing/runner/Views/Main/RunSuite.cshtml deleted file mode 100644 index 68434300097d..000000000000 --- a/packages/devextreme/testing/runner/Views/Main/RunSuite.cshtml +++ /dev/null @@ -1,335 +0,0 @@ -@model RunSuiteViewModel -@{ - var isNoJQueryTest = Model.ScriptVirtualPath.Contains("nojquery"); - var isServerSideTest = Model.ScriptVirtualPath.Contains("DevExpress.serverSide"); - var isSelfSufficientTest = Model.ScriptVirtualPath.Contains("_bundled") - || Model.ScriptVirtualPath.Contains("Bundles") - || Model.ScriptVirtualPath.Contains("DevExpress.jquery"); - - var cspPart = Model.NoCsp ? "" : "-systemjs"; - var npmModule = "transpiled" + cspPart; - var testingBasePath = Model.NoCsp ? "~/packages/devextreme/testing/" : "~/packages/devextreme/artifacts/transpiled-testing/"; - - string GetJQueryUrl() { - if(isNoJQueryTest) - return Url.Content(testingBasePath + "helpers/noJQuery.js"); - - return Url.Content("~/packages/devextreme/artifacts/js/jquery.js"); - } - - string GetTestUrl() { - return Model.NoCsp - ? Url.Content(Model.ScriptVirtualPath) - : Url.Content(Model.ScriptVirtualPath.Replace("/testing/", "/artifacts/transpiled-testing/")); - } - - IEnumerable GetJQueryIntegrationImports() { - if(!isSelfSufficientTest) { - if(Model.NoJQuery || isNoJQueryTest || isServerSideTest) { - yield return Url.Content(testingBasePath + "helpers/jQueryEventsPatch.js"); - yield return Url.Content(testingBasePath + "helpers/argumentsValidator.js"); - yield return Url.Content(testingBasePath + "helpers/dataPatch.js"); - yield return Url.Content("~/packages/devextreme/artifacts/" + npmModule + "/__internal/integration/jquery/component_registrator.js"); - } else { - yield return Url.Content("~/packages/devextreme/artifacts/" + npmModule + "/integration/jquery.js"); - } - } - if(isServerSideTest) { - yield return Url.Content(testingBasePath + "helpers/ssrEmulator.js"); - } - } -} - - - @if(!Model.NoCsp) { - - } - @Model.Title - QUnit test page - - - - - - - - - - - - - - - @if (Model.NoCsp) { - - } else { - - } - - - - -
    -
    - - diff --git a/packages/devextreme/testing/runner/Views/_ViewImports.cshtml b/packages/devextreme/testing/runner/Views/_ViewImports.cshtml deleted file mode 100644 index f4668b0d1f21..000000000000 --- a/packages/devextreme/testing/runner/Views/_ViewImports.cshtml +++ /dev/null @@ -1,2 +0,0 @@ -@using Runner.Models -@using Runner.Tools diff --git a/packages/devextreme/testing/runner/runner.csproj b/packages/devextreme/testing/runner/runner.csproj deleted file mode 100644 index df7252f694e5..000000000000 --- a/packages/devextreme/testing/runner/runner.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - net8.0 - true - Runner.Program - Exe - bin/ - false - portable - PrecompilationTool - - - From 77a5ab75d2c0a6560f29810b199bc85fc43b20ca Mon Sep 17 00:00:00 2001 From: Eugen Zha Date: Wed, 4 Mar 2026 19:19:39 +0200 Subject: [PATCH 03/11] Fix JSON request and improve cache buster handling in runner --- packages/devextreme/testing/runner/index.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/devextreme/testing/runner/index.js b/packages/devextreme/testing/runner/index.js index ff84ec244082..9339eb86e992 100644 --- a/packages/devextreme/testing/runner/index.js +++ b/packages/devextreme/testing/runner/index.js @@ -593,7 +593,7 @@ function renderIndexPage() { }, loadSuites: function() { - $.getJSON(${jsonString(suitesJsonUrl)} + "/" + this.name) + $.getJSON(${jsonString(suitesJsonUrl)}, { id: this.name }) .done(function(suites) { suites = $.map(suites, function(s) { return new SuiteModel(s) }); vm.suites(suites); @@ -2172,7 +2172,8 @@ function setStaticCacheHeaders(res, searchParams) { function getCacheBuster(searchParams) { if(searchParams.has('DX_HTTP_CACHE')) { - return `DX_HTTP_CACHE=${searchParams.get('DX_HTTP_CACHE')}`; + const value = searchParams.get('DX_HTTP_CACHE') || ''; + return `DX_HTTP_CACHE=${encodeURIComponent(value)}`; } return ''; From 6f3b5f600f18248b5e6830bbe36cf1f513912928 Mon Sep 17 00:00:00 2001 From: Eugen Zha Date: Wed, 4 Mar 2026 21:24:22 +0200 Subject: [PATCH 04/11] Add HTML templates for testing runner - Created `index.template.html` for the main testing interface, including category selection and suite display. - Added `run-all.template.html` for running all test suites with worker management and result reporting. - Introduced `run-suite.template.html` for individual suite execution with QUnit integration and CSP support. --- packages/devextreme/testing/runner/index.js | 1071 ++--------------- .../runner/templates/index.template.html | 249 ++++ .../runner/templates/run-all.template.html | 563 +++++++++ .../runner/templates/run-suite.template.html | 163 +++ 4 files changed, 1060 insertions(+), 986 deletions(-) create mode 100644 packages/devextreme/testing/runner/templates/index.template.html create mode 100644 packages/devextreme/testing/runner/templates/run-all.template.html create mode 100644 packages/devextreme/testing/runner/templates/run-suite.template.html diff --git a/packages/devextreme/testing/runner/index.js b/packages/devextreme/testing/runner/index.js index 9339eb86e992..ca3ad94bdc3e 100644 --- a/packages/devextreme/testing/runner/index.js +++ b/packages/devextreme/testing/runner/index.js @@ -13,6 +13,7 @@ const REPO_ROOT = path.resolve(PACKAGE_ROOT, '../..'); const TESTING_ROOT = path.join(PACKAGE_ROOT, 'testing'); const TESTS_ROOT = path.join(TESTING_ROOT, 'tests'); const VECTOR_DATA_DIRECTORY = path.join(TESTING_ROOT, 'content', 'VectorMapData'); +const TEMPLATES_ROOT = path.join(__dirname, 'templates'); const COMPLETED_SUITES_FILENAME = path.join(TESTING_ROOT, 'CompletedSuites.txt'); const LAST_SUITE_TIME_FILENAME = path.join(TESTING_ROOT, 'LastSuiteTime.txt'); @@ -32,6 +33,7 @@ const VECTOR_MAP_TESTER_PORT = Number(PORTS['vectormap-utils-tester']); const PATH_TO_NODE = resolveNodePath(); const logger = createRawLogger(RAW_LOG_FILENAME); +const TEMPLATE_CACHE = new Map(); const vectorMapNodeServer = { process: null, @@ -466,831 +468,32 @@ function isNotEmptyDir(dirPath) { } function renderIndexPage() { - const rootUrl = '/'; - const suitesJsonUrl = '/Main/SuitesJson'; - const categoriesJsonUrl = '/Main/CategoriesJson'; - const jqueryUrl = '/packages/devextreme/artifacts/js/jquery.js'; - const knockoutUrl = '/packages/devextreme/artifacts/js/knockout-latest.js'; - - return ` - - - - - - - -
    -
    - Categories -
    -
    -
    - Select: - all, - none - | RUN -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    - -
      -
    • - - -
    • -
    -
    - Greyed are explicit tests -
    -
    - -
    -
    - Suites for category "" - (Refresh) -
    -
    - -
      -
    • - -
    • -
    -
    - - - - -`; } function renderRunAllPage(model, runProps) { - const jqueryUrl = '/packages/devextreme/artifacts/js/jquery.js'; - - return ` - - - QUnit All Suites test page - - - - - - - - - -
    - -
    -
    -
    -

    -

    -
    -
    - -`; } function renderRunSuitePage(model, runProps, searchParams) { @@ -1463,10 +666,9 @@ function renderRunSuitePage(model, runProps, searchParams) { }; const integrationImportPaths = getJQueryIntegrationImports(); - - return ` - - ${runProps.NoCsp ? '' : ``} - ${escapeHtml(model.Title)} - QUnit test page - - - - - - - - - - - - - - - - - - - -
    -
    - - -`; + />`; + + return renderTemplate('run-suite.template.html', { + CSP_META_TAG: cspMetaTag, + TITLE: model.Title, + QUNIT_CSS_URL: qunitCss, + QUNIT_JS_URL: qunitJs, + QUNIT_EXTENSIONS_JS_URL: qunitExtensionsJs, + JQUERY_JS_URL: jqueryJs, + SINON_JS_URL: sinonJs, + SYSTEM_JS_URL: systemJs, + IS_CONTINUOUS_INTEGRATION_JSON: jsonString(runProps.IsContinuousIntegration), + CACHE_BUSTER_JSON: jsonString(cacheBuster), + SYSTEM_CONFIG_JSON: jsonString(systemConfig), + INTEGRATION_IMPORT_PATHS_JSON: jsonString(integrationImportPaths), + IS_SERVER_SIDE_TEST_JSON: jsonString(isServerSideTest), + TEST_URL_JSON: jsonString(getTestUrl()), + }); } async function saveResults(req, res) { @@ -2240,6 +1298,47 @@ function jsonString(value) { return JSON.stringify(value); } +function renderTemplate(templateName, vars) { + const template = readTemplate(templateName); + const data = vars || {}; + + return template + .replace(/\{\{\{([A-Za-z0-9_]+)\}\}\}/g, (_, key) => getTemplateValue(data, key, false)) + .replace(/\{\{([A-Za-z0-9_]+)\}\}/g, (_, key) => getTemplateValue(data, key, true)); +} + +function readTemplate(templateName) { + const key = String(templateName || ''); + + if(TEMPLATE_CACHE.has(key)) { + return TEMPLATE_CACHE.get(key); + } + + const filePath = path.resolve(TEMPLATES_ROOT, key); + const relativePath = path.relative(TEMPLATES_ROOT, filePath); + + if(relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + throw new Error(`Invalid template path: ${key}`); + } + + const templateText = fs.readFileSync(filePath, 'utf8'); + TEMPLATE_CACHE.set(key, templateText); + + return templateText; +} + +function getTemplateValue(data, key, shouldEscape) { + const hasValue = Object.prototype.hasOwnProperty.call(data, key); + const value = hasValue ? data[key] : ''; + const valueAsString = value === null || value === undefined ? '' : String(value); + + if(shouldEscape) { + return escapeHtml(valueAsString); + } + + return valueAsString; +} + function escapeHtml(value) { return String(value) .replace(/&/g, '&') diff --git a/packages/devextreme/testing/runner/templates/index.template.html b/packages/devextreme/testing/runner/templates/index.template.html new file mode 100644 index 000000000000..2ffc4ec3a064 --- /dev/null +++ b/packages/devextreme/testing/runner/templates/index.template.html @@ -0,0 +1,249 @@ + + + + + + + + +
    +
    + Categories +
    +
    +
    + Select: + all, + none + | RUN +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    + +
      +
    • + + +
    • +
    +
    + Greyed are explicit tests +
    +
    + +
    +
    + Suites for category "" + (Refresh) +
    +
    + +
      +
    • + +
    • +
    +
    + + + + diff --git a/packages/devextreme/testing/runner/templates/run-all.template.html b/packages/devextreme/testing/runner/templates/run-all.template.html new file mode 100644 index 000000000000..dd2086b472b6 --- /dev/null +++ b/packages/devextreme/testing/runner/templates/run-all.template.html @@ -0,0 +1,563 @@ + + + + QUnit All Suites test page + + + + + + + + + +
    + +
    +
    +
    +

    +

    +
    +
    + diff --git a/packages/devextreme/testing/runner/templates/run-suite.template.html b/packages/devextreme/testing/runner/templates/run-suite.template.html new file mode 100644 index 000000000000..f991d208e9b7 --- /dev/null +++ b/packages/devextreme/testing/runner/templates/run-suite.template.html @@ -0,0 +1,163 @@ + + + {{{CSP_META_TAG}}} + {{TITLE}} - QUnit test page + + + + + + + + + + + + + + + + + + + +
    +
    + + From f86f9d271dcf928fb7a1731d87b3e7dac9e276be Mon Sep 17 00:00:00 2001 From: Eugen Zha Date: Wed, 4 Mar 2026 21:32:23 +0200 Subject: [PATCH 05/11] Fix CI --- packages/devextreme/testing/runner/index.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/devextreme/testing/runner/index.js b/packages/devextreme/testing/runner/index.js index ca3ad94bdc3e..385f4b56654d 100644 --- a/packages/devextreme/testing/runner/index.js +++ b/packages/devextreme/testing/runner/index.js @@ -87,7 +87,16 @@ async function handleRequest(req, res) { return sendJson(res, readCategories()); } - if(req.method === 'GET' && (pathnameLower === '/run' || pathnameLower === '/run/' || pathnameLower === '/main/runall')) { + if((req.method === 'GET' || req.method === 'HEAD') + && (pathnameLower === '/run' || pathnameLower === '/run/' || pathnameLower === '/main/runall')) { + if(req.method === 'HEAD') { + setNoCacheHeaders(res); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(); + return; + } + const model = buildRunAllModel(requestUrl.searchParams); const runProps = assignBaseRunProps(requestUrl.searchParams); return sendHtml(res, renderRunAllPage(model, runProps)); From faaa990c28e335930e2b2d8e675c19733462a90b Mon Sep 17 00:00:00 2001 From: Eugen Zha Date: Wed, 4 Mar 2026 21:56:28 +0200 Subject: [PATCH 06/11] Fix CI pt 2 --- packages/devextreme/testing/runner/index.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/devextreme/testing/runner/index.js b/packages/devextreme/testing/runner/index.js index 385f4b56654d..06f4f8dacfe0 100644 --- a/packages/devextreme/testing/runner/index.js +++ b/packages/devextreme/testing/runner/index.js @@ -583,12 +583,12 @@ function renderRunSuitePage(model, runProps, searchParams) { 'devextreme-cldr-data': '/packages/devextreme/artifacts/js-systemjs/devextreme-cldr-data', 'cldr-core': '/packages/devextreme/artifacts/js-systemjs/cldr-core', json: '/packages/devextreme/artifacts/js-systemjs/json.js', - '@@preact/signals-core': '/packages/devextreme/artifacts/js-systemjs/preact-signals.js', + '@preact/signals-core': '/packages/devextreme/artifacts/js-systemjs/preact-signals.js', } : { 'devextreme-cldr-data': '/packages/devextreme/node_modules/devextreme-cldr-data', 'cldr-core': '/packages/devextreme/node_modules/cldr-core', - '@@preact/signals-core': '/packages/devextreme/node_modules/@preact/signals-core/dist/signals-core.js', + '@preact/signals-core': '/packages/devextreme/node_modules/@preact/signals-core/dist/signals-core.js', }; const systemMap = { @@ -898,6 +898,10 @@ function readThemeCssFiles() { const bundlesPath = path.join(PACKAGE_ROOT, 'scss', 'bundles'); const result = []; + if(!fs.existsSync(bundlesPath)) { + return result; + } + fs.readdirSync(bundlesPath, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .forEach((entry) => { @@ -1042,7 +1046,11 @@ function executeVectorMapConsoleApp(arg, searchParams) { const index = text.indexOf('='); if(index > 0) { variable = text.substring(0, index).trim(); - text = text.substring(index + 1, text.length - 2).trim(); + text = text.substring(index + 1).trim(); + + if(text.endsWith(';')) { + text = text.slice(0, -1).trim(); + } } } From 0419cb148aa6b9a37725e48d03d459c7057cc504 Mon Sep 17 00:00:00 2001 From: Eugen Zha Date: Wed, 4 Mar 2026 22:57:12 +0200 Subject: [PATCH 07/11] Refactor --- packages/devextreme/testing/runner/index.js | 1298 ++--------------- .../devextreme/testing/runner/lib/http.js | 66 + .../devextreme/testing/runner/lib/logger.js | 83 ++ .../devextreme/testing/runner/lib/pages.js | 247 ++++ .../devextreme/testing/runner/lib/results.js | 161 ++ .../devextreme/testing/runner/lib/static.js | 165 +++ .../devextreme/testing/runner/lib/suites.js | 156 ++ .../testing/runner/lib/templates.js | 55 + .../devextreme/testing/runner/lib/utils.js | 164 +++ .../testing/runner/lib/vectormap.js | 222 +++ 10 files changed, 1416 insertions(+), 1201 deletions(-) create mode 100644 packages/devextreme/testing/runner/lib/http.js create mode 100644 packages/devextreme/testing/runner/lib/logger.js create mode 100644 packages/devextreme/testing/runner/lib/pages.js create mode 100644 packages/devextreme/testing/runner/lib/results.js create mode 100644 packages/devextreme/testing/runner/lib/static.js create mode 100644 packages/devextreme/testing/runner/lib/suites.js create mode 100644 packages/devextreme/testing/runner/lib/templates.js create mode 100644 packages/devextreme/testing/runner/lib/utils.js create mode 100644 packages/devextreme/testing/runner/lib/vectormap.js diff --git a/packages/devextreme/testing/runner/index.js b/packages/devextreme/testing/runner/index.js index 06f4f8dacfe0..e3043cc8a36b 100644 --- a/packages/devextreme/testing/runner/index.js +++ b/packages/devextreme/testing/runner/index.js @@ -4,12 +4,49 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const http = require('http'); -const { spawn, spawnSync } = require('child_process'); + +const { + contentWithCacheBuster, + escapeHtml, + escapeXmlAttr, + escapeXmlText, + formatDateForSuiteTimestamp, + getCacheBuster, + isContinuousIntegration, + jsonString, + loadPorts, + normalizeNumber, + parseBoolean, + parseNumber, + readBodyText, + readFormBody, + resolveNodePath, + safeDecodeURIComponent, + safeReadFile, + splitCommaList, +} = require('./lib/utils'); +const { createRunnerLogger } = require('./lib/logger'); +const { createTemplateRenderer } = require('./lib/templates'); +const { createPagesRenderer } = require('./lib/pages'); +const { createSuitesService } = require('./lib/suites'); +const { createResultsReporter } = require('./lib/results'); +const { createVectorMapService } = require('./lib/vectormap'); +const { + sendHtml, + sendJson, + sendJsonText, + sendNotFound, + sendText, + sendXml, + setNoCacheHeaders, + setStaticCacheHeaders, +} = require('./lib/http'); +const { createStaticFileService } = require('./lib/static'); const KNOWN_CONSTELLATIONS = new Set(['export', 'misc', 'ui', 'ui.widgets', 'ui.editors', 'ui.grid', 'ui.scheduler']); const PACKAGE_ROOT = path.resolve(__dirname, '../..'); -const REPO_ROOT = path.resolve(PACKAGE_ROOT, '../..'); +const WORKSPACE_ROOT = path.resolve(PACKAGE_ROOT, '../..'); const TESTING_ROOT = path.join(PACKAGE_ROOT, 'testing'); const TESTS_ROOT = path.join(TESTING_ROOT, 'tests'); const VECTOR_DATA_DIRECTORY = path.join(TESTING_ROOT, 'content', 'VectorMapData'); @@ -32,21 +69,43 @@ const VECTOR_MAP_TESTER_PORT = Number(PORTS['vectormap-utils-tester']); const PATH_TO_NODE = resolveNodePath(); -const logger = createRawLogger(RAW_LOG_FILENAME); -const TEMPLATE_CACHE = new Map(); - -const vectorMapNodeServer = { - process: null, - refs: 0, - killTimer: null, -}; +const logger = createRunnerLogger(RAW_LOG_FILENAME); +const templates = createTemplateRenderer(TEMPLATES_ROOT, escapeHtml); +const pages = createPagesRenderer({ + contentWithCacheBuster, + getCacheBuster, + jsonString, + renderTemplate: templates.renderTemplate, +}); +const suites = createSuitesService({ + knownConstellations: KNOWN_CONSTELLATIONS, + testsRoot: TESTS_ROOT, +}); +const results = createResultsReporter({ + escapeXmlAttr, + escapeXmlText, + normalizeNumber, +}); +const vectorMap = createVectorMapService({ + packageRoot: PACKAGE_ROOT, + testingRoot: TESTING_ROOT, + vectorDataDirectory: VECTOR_DATA_DIRECTORY, + vectorMapTesterPort: VECTOR_MAP_TESTER_PORT, + pathToNode: PATH_TO_NODE, +}); +const staticFiles = createStaticFileService({ + escapeHtml, + rootDirectory: WORKSPACE_ROOT, + setNoCacheHeaders, + setStaticCacheHeaders, +}); start(); function start() { const server = http.createServer((req, res) => { handleRequest(req, res).catch((error) => { - writeError(error && error.stack ? error.stack : String(error)); + logger.writeError(error && error.stack ? error.stack : String(error)); if(!res.headersSent) { setNoCacheHeaders(res); res.statusCode = 500; @@ -59,7 +118,7 @@ function start() { }); server.listen(QUNIT_PORT, '0.0.0.0', () => { - writeLine(`QUnit runner server listens on http://0.0.0.0:${QUNIT_PORT}...`); + logger.writeLine(`QUnit runner server listens on http://0.0.0.0:${QUNIT_PORT}...`); }); } @@ -69,7 +128,7 @@ async function handleRequest(req, res) { const pathnameLower = pathname.toLowerCase(); if(req.method === 'GET' && (pathname === '/' || pathnameLower === '/main/index')) { - return sendHtml(res, renderIndexPage()); + return sendHtml(res, pages.renderIndexPage()); } if(req.method === 'GET') { @@ -78,13 +137,13 @@ async function handleRequest(req, res) { const id = suitesJsonMatch[1] ? safeDecodeURIComponent(suitesJsonMatch[1]) : requestUrl.searchParams.get('id'); - const suites = readSuites(id || ''); - return sendJson(res, suites); + const suitesList = suites.readSuites(id || ''); + return sendJson(res, suitesList); } } if(req.method === 'GET' && pathnameLower === '/main/categoriesjson') { - return sendJson(res, readCategories()); + return sendJson(res, suites.readCategories()); } if((req.method === 'GET' || req.method === 'HEAD') @@ -99,7 +158,7 @@ async function handleRequest(req, res) { const model = buildRunAllModel(requestUrl.searchParams); const runProps = assignBaseRunProps(requestUrl.searchParams); - return sendHtml(res, renderRunAllPage(model, runProps)); + return sendHtml(res, pages.renderRunAllPage(model, runProps)); } if(req.method === 'GET') { @@ -107,9 +166,9 @@ async function handleRequest(req, res) { if(runSuiteMatch) { const catName = safeDecodeURIComponent(runSuiteMatch[1]); const suiteName = safeDecodeURIComponent(runSuiteMatch[2]); - const model = buildRunSuiteModel(catName, suiteName); + const model = suites.buildRunSuiteModel(catName, suiteName); const runProps = assignBaseRunProps(requestUrl.searchParams); - return sendHtml(res, renderRunSuitePage(model, runProps, requestUrl.searchParams)); + return sendHtml(res, pages.renderRunSuitePage(model, runProps, requestUrl.searchParams)); } } @@ -121,9 +180,9 @@ async function handleRequest(req, res) { return sendNotFound(res); } - const model = buildRunSuiteModel(catName, suiteName); + const model = suites.buildRunSuiteModel(catName, suiteName); const runProps = assignBaseRunProps(requestUrl.searchParams); - return sendHtml(res, renderRunSuitePage(model, runProps, requestUrl.searchParams)); + return sendHtml(res, pages.renderRunSuitePage(model, runProps, requestUrl.searchParams)); } if(req.method === 'POST' && pathnameLower === '/main/notifyteststarted') { @@ -131,7 +190,7 @@ async function handleRequest(req, res) { const name = String(form.name || ''); try { - writeLine(` [ run] ${name}`); + logger.writeLine(` [ run] ${name}`); } catch(_) { // Ignore logging errors. } @@ -145,7 +204,7 @@ async function handleRequest(req, res) { const passed = parseBoolean(form.passed); try { - writeLine(` [${passed ? ' ok' : 'fail'}] ${name}`); + logger.writeLine(` [${passed ? ' ok' : 'fail'}] ${name}`); } catch(_) { // Ignore logging errors. } @@ -168,9 +227,9 @@ async function handleRequest(req, res) { writeLastSuiteTime(); } - write(passed ? '[ OK ' : '[FAIL', passed ? 'green' : 'red'); + logger.write(passed ? '[ OK ' : '[FAIL', passed ? 'green' : 'red'); const seconds = Number((runtime / 1000).toFixed(3)); - writeLine(`] ${name} in ${seconds}s`); + logger.writeLine(`] ${name} in ${seconds}s`); } catch(_) { // Preserve legacy behavior: swallow errors. } @@ -216,12 +275,12 @@ async function handleRequest(req, res) { } if(req.method === 'GET' && pathnameLower === '/themes-test/get-css-files-list') { - const list = readThemeCssFiles(); + const list = vectorMap.readThemeCssFiles(); return sendJson(res, list); } if(req.method === 'GET' && pathnameLower === '/testvectormapdata/gettestdata') { - const data = readVectorMapTestData(); + const data = vectorMap.readVectorMapTestData(); return sendJson(res, data); } @@ -229,7 +288,7 @@ async function handleRequest(req, res) { const parseBufferMatch = pathname.match(/^\/TestVectorMapData\/ParseBuffer\/(.+)$/i); if(parseBufferMatch) { const id = safeDecodeURIComponent(parseBufferMatch[1]); - const responseText = await redirectRequestToVectorMapNodeServer('parse-buffer', id); + const responseText = await vectorMap.redirectRequestToVectorMapNodeServer('parse-buffer', id); return sendJsonText(res, responseText); } } @@ -238,7 +297,7 @@ async function handleRequest(req, res) { const readAndParseMatch = pathname.match(/^\/TestVectorMapData\/ReadAndParse\/(.+)$/i); if(readAndParseMatch) { const id = safeDecodeURIComponent(readAndParseMatch[1]); - const responseText = await redirectRequestToVectorMapNodeServer('read-and-parse', id); + const responseText = await vectorMap.redirectRequestToVectorMapNodeServer('read-and-parse', id); return sendJsonText(res, responseText); } } @@ -247,25 +306,18 @@ async function handleRequest(req, res) { const executeConsoleAppMatch = pathname.match(/^\/TestVectorMapData\/ExecuteConsoleApp(?:\/(.*))?$/i); if(executeConsoleAppMatch) { const arg = safeDecodeURIComponent(executeConsoleAppMatch[1] || ''); - const result = executeVectorMapConsoleApp(arg, requestUrl.searchParams); + const result = vectorMap.executeVectorMapConsoleApp(arg, requestUrl.searchParams); return sendJson(res, result); } } - if(await tryServeStatic(req, res, pathname, requestUrl.searchParams)) { + if(staticFiles.tryServeStatic(req, res, pathname, requestUrl.searchParams)) { return; } return sendNotFound(res); } -function buildRunSuiteModel(catName, suiteName) { - return { - Title: suiteName, - ScriptVirtualPath: getSuiteVirtualPath(catName, suiteName), - }; -} - function buildRunAllModel(searchParams) { let includeSet = null; let excludeSet = null; @@ -309,7 +361,7 @@ function buildRunAllModel(searchParams) { Constellation: constellation || '', CategoriesList: include || '', Version: String(packageJson.version || ''), - Suites: getAllSuites({ + Suites: suites.getAllSuites({ deviceMode: hasDeviceModeFlag(searchParams), constellation: constellation || '', includeCategories: includeSet, @@ -345,383 +397,21 @@ function hasDeviceModeFlag(searchParams) { return searchParams.has('deviceMode'); } -function readCategories() { - const dirs = fs.readdirSync(TESTS_ROOT, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(TESTS_ROOT, entry.name)) - .filter(isNotEmptyDir) - .map(categoryFromPath) - .sort((a, b) => a.Name.localeCompare(b.Name)); - - return dirs; -} - -function readSuites(catName) { - if(!catName) { - throw new Error('Category name is required.'); - } - - const catPath = path.join(TESTS_ROOT, catName); - - const subDirs = fs.readdirSync(catPath, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name); - - subDirs.forEach((dirName) => { - if(!dirName.endsWith('Parts')) { - throw new Error(`Unexpected sub-directory in the test category: ${path.join(catPath, dirName)}`); - } - }); - - const suites = fs.readdirSync(catPath, { withFileTypes: true }) - .filter((entry) => entry.isFile() && entry.name.endsWith('.js')) - .map((entry) => suiteFromPath(catName, path.join(catPath, entry.name))) - .sort((a, b) => a.ShortName.localeCompare(b.ShortName)); - - return suites; -} - -function getSuiteVirtualPath(catName, suiteName) { - return `/packages/devextreme/testing/tests/${catName}/${suiteName}`; -} - -function getAllSuites({ - deviceMode, - constellation, - includeCategories, - excludeCategories, - excludeSuites, - partIndex, - partCount, -}) { - const includeSpecified = includeCategories && includeCategories.size > 0; - const excludeSpecified = excludeCategories && excludeCategories.size > 0; - const result = []; - - readCategories().forEach((category) => { - if(deviceMode && !category.RunOnDevices) { - return; - } - - if(constellation && category.Constellation !== constellation) { - return; - } - - if(includeSpecified && !includeCategories.has(category.Name)) { - return; - } - - if(category.Explicit && (!includeSpecified || !includeCategories.has(category.Name))) { - return; - } - - if(excludeSpecified && excludeCategories.has(category.Name)) { - return; - } - - let index = 0; - readSuites(category.Name).forEach((suite) => { - if(partCount > 1 && (index % partCount) !== partIndex) { - index += 1; - return; - } - - index += 1; - - if(excludeSuites && excludeSuites.has(suite.FullName)) { - return; - } - - result.push(suite); - }); - }); - - return result; -} - -function categoryFromPath(categoryPath) { - const name = path.basename(categoryPath); - const metaPath = path.join(categoryPath, '__meta.json'); - const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); - const constellation = String(meta.constellation || ''); - - if(!KNOWN_CONSTELLATIONS.has(constellation)) { - throw new Error(`Unknown constellation (group of categories):${constellation}`); - } - - return { - Name: name, - Constellation: constellation, - Explicit: Boolean(meta.explicit), - RunOnDevices: Boolean(meta.runOnDevices), - }; -} - -function suiteFromPath(catName, suitePath) { - const suiteName = path.basename(suitePath); - const shortName = path.basename(suitePath, '.js'); - - return { - ShortName: shortName, - FullName: `${catName}/${suiteName}`, - Url: `/run/${encodeURIComponent(catName)}/${encodeURIComponent(suiteName)}`, - }; -} - -function isNotEmptyDir(dirPath) { - try { - return fs.readdirSync(dirPath).length > 0; - } catch(_) { - return false; - } -} - -function renderIndexPage() { - return renderTemplate('index.template.html', { - JQUERY_URL: '/packages/devextreme/artifacts/js/jquery.js', - KNOCKOUT_URL: '/packages/devextreme/artifacts/js/knockout-latest.js', - ROOT_URL_JSON: jsonString('/'), - SUITES_JSON_URL_JSON: jsonString('/Main/SuitesJson'), - CATEGORIES_JSON_URL_JSON: jsonString('/Main/CategoriesJson'), - }); -} - -function renderRunAllPage(model, runProps) { - return renderTemplate('run-all.template.html', { - JQUERY_URL: '/packages/devextreme/artifacts/js/jquery.js', - CONSTELLATION_JSON: jsonString(model.Constellation), - CATEGORIES_LIST_JSON: jsonString(model.CategoriesList), - VERSION_JSON: jsonString(model.Version), - SUITES_JSON: jsonString(model.Suites), - NO_TRY_CATCH_JSON: jsonString(runProps.NoTryCatch), - NO_GLOBALS_JSON: jsonString(runProps.NoGlobals), - NO_TIMERS_JSON: jsonString(runProps.NoTimers), - NO_JQUERY_JSON: jsonString(runProps.NoJQuery), - SHADOW_DOM_JSON: jsonString(runProps.ShadowDom), - NO_CSP_JSON: jsonString(runProps.NoCsp), - IS_CONTINUOUS_INTEGRATION_JSON: jsonString(runProps.IsContinuousIntegration), - WORKER_IN_WINDOW_JSON: jsonString(runProps.WorkerInWindow), - MAX_WORKERS_JSON: jsonString(runProps.MaxWorkers), - }); -} - -function renderRunSuitePage(model, runProps, searchParams) { - const scriptVirtualPath = model.ScriptVirtualPath; - const isNoJQueryTest = scriptVirtualPath.includes('nojquery'); - const isServerSideTest = scriptVirtualPath.includes('DevExpress.serverSide'); - const isSelfSufficientTest = scriptVirtualPath.includes('_bundled') - || scriptVirtualPath.includes('Bundles') - || scriptVirtualPath.includes('DevExpress.jquery'); - - const cspPart = runProps.NoCsp ? '' : '-systemjs'; - const npmModule = `transpiled${cspPart}`; - const testingBasePath = runProps.NoCsp - ? '/packages/devextreme/testing/' - : '/packages/devextreme/artifacts/transpiled-testing/'; - - function getJQueryUrl() { - if(isNoJQueryTest) { - return `${testingBasePath}helpers/noJQuery.js`; - } - - return '/packages/devextreme/artifacts/js/jquery.js'; - } - - function getTestUrl() { - if(runProps.NoCsp) { - return scriptVirtualPath; - } - - return scriptVirtualPath.replace('/testing/', '/artifacts/transpiled-testing/'); - } - - function getJQueryIntegrationImports() { - const result = []; - - if(!isSelfSufficientTest) { - if(runProps.NoJQuery || isNoJQueryTest || isServerSideTest) { - result.push(`${testingBasePath}helpers/jQueryEventsPatch.js`); - result.push(`${testingBasePath}helpers/argumentsValidator.js`); - result.push(`${testingBasePath}helpers/dataPatch.js`); - result.push(`/packages/devextreme/artifacts/${npmModule}/__internal/integration/jquery/component_registrator.js`); - } else { - result.push(`/packages/devextreme/artifacts/${npmModule}/integration/jquery.js`); - } - } - - if(isServerSideTest) { - result.push(`${testingBasePath}helpers/ssrEmulator.js`); - } - - return result; - } - - const cacheBuster = getCacheBuster(searchParams); - - const qunitCss = contentWithCacheBuster('/packages/devextreme/node_modules/qunit/qunit/qunit.css', cacheBuster); - const qunitJs = contentWithCacheBuster('/packages/devextreme/node_modules/qunit/qunit/qunit.js', cacheBuster); - 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 cspMap = !runProps.NoCsp - ? { - 'inferno-create-element': '/packages/devextreme/node_modules/inferno-create-element/dist/inferno-create-element.js', - intl: '/packages/devextreme/artifacts/js-systemjs/intl/index.js', - knockout: '/packages/devextreme/artifacts/js-systemjs/knockout.js', - css: '/packages/devextreme/artifacts/js-systemjs/css.js', - 'generic_light.css': '/packages/devextreme/artifacts/css-systemjs/dx.light.css', - 'material_blue_light.css': '/packages/devextreme/artifacts/css-systemjs/dx.material.blue.light.css', - 'fluent_blue_light.css': '/packages/devextreme/artifacts/css-systemjs/dx.fluent.blue.light.css', - 'gantt.css': '/packages/devextreme/artifacts/css-systemjs/dx-gantt.css', - 'devextreme-cldr-data': '/packages/devextreme/artifacts/js-systemjs/devextreme-cldr-data', - 'cldr-core': '/packages/devextreme/artifacts/js-systemjs/cldr-core', - json: '/packages/devextreme/artifacts/js-systemjs/json.js', - '@preact/signals-core': '/packages/devextreme/artifacts/js-systemjs/preact-signals.js', - } - : { - 'devextreme-cldr-data': '/packages/devextreme/node_modules/devextreme-cldr-data', - 'cldr-core': '/packages/devextreme/node_modules/cldr-core', - '@preact/signals-core': '/packages/devextreme/node_modules/@preact/signals-core/dist/signals-core.js', - }; - - const systemMap = { - globalize: '/packages/devextreme/node_modules/globalize/dist/globalize', - intl: '/packages/devextreme/node_modules/intl/index.js', - cldr: '/packages/devextreme/node_modules/cldrjs/dist/cldr', - jquery: getJQueryUrl(), - knockout: '/packages/devextreme/node_modules/knockout/build/output/knockout-latest.debug.js', - jszip: '/packages/devextreme/artifacts/js/jszip.js', - underscore: '/packages/devextreme/node_modules/underscore/underscore-min.js', - '@@devextreme/vdom': '/packages/devextreme/node_modules/@devextreme/vdom', - 'devextreme-quill': '/packages/devextreme/node_modules/devextreme-quill/dist/dx-quill.js', - 'devexpress-diagram': '/packages/devextreme/artifacts/js/dx-diagram.js', - 'devexpress-gantt': '/packages/devextreme/artifacts/js/dx-gantt.js', - 'devextreme-exceljs-fork': '/packages/devextreme/node_modules/devextreme-exceljs-fork/dist/dx-exceljs-fork.js', - 'fflate': '/packages/devextreme/node_modules/fflate/esm/browser.js', - jspdf: '/packages/devextreme/node_modules/jspdf/dist/jspdf.umd.js', - 'jspdf-autotable': '/packages/devextreme/node_modules/jspdf-autotable/dist/jspdf.plugin.autotable.js', - rrule: '/packages/devextreme/node_modules/rrule/dist/es5/rrule.js', - inferno: '/packages/devextreme/node_modules/inferno/dist/inferno.js', - 'inferno-hydrate': '/packages/devextreme/node_modules/inferno-hydrate/dist/inferno-hydrate.js', - 'inferno-compat': '/packages/devextreme/node_modules/inferno-compat/dist/inferno-compat.js', - 'inferno-clone-vnode': '/packages/devextreme/node_modules/inferno-clone-vnode/dist/index.cjs.js', - 'inferno-create-element': '/packages/devextreme/node_modules/inferno-create-element/dist/index.cjs.js', - 'inferno-create-class': '/packages/devextreme/node_modules/inferno-create-class/dist/index.cjs.js', - 'inferno-extras': '/packages/devextreme/node_modules/inferno-extras/dist/index.cjs.js', - 'generic_light.css': '/packages/devextreme/artifacts/css/dx.light.css', - 'material_blue_light.css': '/packages/devextreme/artifacts/css/dx.material.blue.light.css', - 'fluent_blue_light.css': '/packages/devextreme/artifacts/css/dx.fluent.blue.light.css', - 'gantt.css': '/packages/devextreme/artifacts/css/dx-gantt.css', - css: '/packages/devextreme/node_modules/systemjs-plugin-css/css.js', - text: '/packages/devextreme/node_modules/systemjs-plugin-text/text.js', - json: '/packages/devextreme/node_modules/systemjs-plugin-json/json.js', - 'plugin-babel': '/packages/devextreme/node_modules/systemjs-plugin-babel/plugin-babel.js', - 'systemjs-babel-build': '/packages/devextreme/node_modules/systemjs-plugin-babel/systemjs-babel-browser.js', - ...cspMap, - }; - - const systemPackages = { - '': { - defaultExtension: 'js', - }, - globalize: { - main: '../globalize.js', - defaultExtension: 'js', - }, - cldr: { - main: '../cldr.js', - defaultExtension: 'js', - }, - 'common/core/events/utils': { - main: 'index', - }, - 'events/utils': { - main: 'index', - }, - events: { - main: 'index', - }, - }; - - const knockoutPath = '/packages/devextreme/node_modules/knockout/build/output/knockout-latest.debug.js'; - - const systemConfig = { - baseURL: `/packages/devextreme/artifacts/${npmModule}`, - transpiler: 'plugin-babel', - map: systemMap, - packages: systemPackages, - packageConfigPaths: [ - '@@devextreme/*/package.json', - ], - meta: { - [knockoutPath]: { - format: 'global', - deps: ['jquery'], - exports: 'ko', - }, - '*.js': { - babelOptions: { - es2015: false, - }, - }, - }, - }; - - const integrationImportPaths = getJQueryIntegrationImports(); - const cspMetaTag = runProps.NoCsp - ? '' - : ``; - - return renderTemplate('run-suite.template.html', { - CSP_META_TAG: cspMetaTag, - TITLE: model.Title, - QUNIT_CSS_URL: qunitCss, - QUNIT_JS_URL: qunitJs, - QUNIT_EXTENSIONS_JS_URL: qunitExtensionsJs, - JQUERY_JS_URL: jqueryJs, - SINON_JS_URL: sinonJs, - SYSTEM_JS_URL: systemJs, - IS_CONTINUOUS_INTEGRATION_JSON: jsonString(runProps.IsContinuousIntegration), - CACHE_BUSTER_JSON: jsonString(cacheBuster), - SYSTEM_CONFIG_JSON: jsonString(systemConfig), - INTEGRATION_IMPORT_PATHS_JSON: jsonString(integrationImportPaths), - IS_SERVER_SIDE_TEST_JSON: jsonString(isServerSideTest), - TEST_URL_JSON: jsonString(getTestUrl()), - }); -} - async function saveResults(req, res) { let hasFailure = false; let xml = ''; try { const json = await readBodyText(req); - validateResultsJson(json); + results.validateResultsJson(json); - const results = JSON.parse(json); - hasFailure = Number(results.failures) > 0; - xml = testResultsToXml(results); + const parsedResults = JSON.parse(json); + hasFailure = Number(parsedResults.failures) > 0; + xml = results.testResultsToXml(parsedResults); if(RUN_FLAGS.singleRun) { - writeLine(); - printTextReport(results); + logger.writeLine(); + results.printTextReport(parsedResults, logger.writeLine.bind(logger)); } } catch(error) { logMiscErrorCore(`Failed to save results. ${error && error.stack ? error.stack : String(error)}`); @@ -739,712 +429,10 @@ async function saveResults(req, res) { } } -function validateResultsJson(json) { - const badToken = '\\u0000'; - const badIndex = json.indexOf(badToken); - - if(badIndex > -1) { - const from = Math.max(0, badIndex - 200); - const to = Math.min(json.length, badIndex + 200); - throw new Error(`Result JSON has bad content: ${json.slice(from, to)}`); - } -} - -function printTextReport(results) { - const maxWrittenFailures = 50; - const notRunCases = []; - const failedCases = []; - - (results.suites || []).forEach((suite) => { - enumerateAllCases(suite, (testCase) => { - if(testCase && testCase.reason) { - notRunCases.push(testCase); - } - if(testCase && testCase.failure) { - failedCases.push(testCase); - } - }); - }); - - const total = Number(results.total) || 0; - const failures = Number(results.failures) || 0; - const notRunCount = notRunCases.length; - const color = failures > 0 ? 'red' : (notRunCount > 0 ? 'yellow' : 'green'); - - writeLine(`Tests run: ${total}, Failures: ${failures}, Not run: ${notRunCount}`, color); - - if(notRunCount > 0 && failures === 0) { - notRunCases.forEach((testCase) => { - writeLine('-'.repeat(80)); - writeLine(`Skipped: ${testCase.name || ''}`); - writeLine(`Reason: ${testCase.reason && testCase.reason.message ? testCase.reason.message : ''}`); - }); - } - - if(failures > 0) { - let writtenFailures = 0; - - failedCases.forEach((testCase) => { - if(writtenFailures >= maxWrittenFailures) { - return; - } - - writeLine('-'.repeat(80)); - writeLine(testCase.name || '', 'white'); - writeLine(); - writeLine(testCase.failure && testCase.failure.message ? testCase.failure.message : ''); - - writtenFailures += 1; - }); - - if(writtenFailures >= maxWrittenFailures) { - writeLine(`WARNING: only first ${maxWrittenFailures} failures are shown.`); - } - } -} - -function enumerateAllCases(suite, callback) { - (suite.results || []).forEach((item) => { - if(item && Array.isArray(item.results)) { - enumerateAllCases(item, callback); - return; - } - - callback(item); - }); -} - -function testResultsToXml(results) { - const lines = []; - - lines.push(``); - - (results.suites || []).forEach((suite) => { - lines.push(renderSuiteXml(suite, ' ')); - }); - - lines.push(''); - - return `${lines.join('\n')}\n`; -} - -function renderSuiteXml(suite, indent) { - const lines = []; - - lines.push(`${indent}`); - lines.push(`${indent} `); - - (suite.results || []).forEach((item) => { - if(item && Array.isArray(item.results)) { - lines.push(renderSuiteXml(item, `${indent} `)); - } else { - lines.push(renderCaseXml(item || {}, `${indent} `)); - } - }); - - lines.push(`${indent} `); - lines.push(`${indent}`); - - return lines.join('\n'); -} - -function renderCaseXml(testCase, indent) { - const attributes = [ - `name="${escapeXmlAttr(testCase.name || '')}"`, - `url="${escapeXmlAttr(testCase.url || '')}"`, - `time="${escapeXmlAttr(testCase.time || '')}"`, - ]; - - if(testCase.executed === false) { - attributes.push('executed="false"'); - } - - const hasFailure = Boolean(testCase.failure && typeof testCase.failure.message === 'string'); - const hasReason = Boolean(testCase.reason && typeof testCase.reason.message === 'string'); - - if(!hasFailure && !hasReason) { - return `${indent}`; - } - - const lines = [`${indent}`]; - - if(hasFailure) { - lines.push(`${indent} `); - lines.push(`${indent} ${escapeXmlText(testCase.failure.message)}`); - lines.push(`${indent} `); - } - - if(hasReason) { - lines.push(`${indent} `); - lines.push(`${indent} ${escapeXmlText(testCase.reason.message)}`); - lines.push(`${indent} `); - } - - lines.push(`${indent}`); - - return lines.join('\n'); -} - -function normalizeNumber(value) { - const number = Number(value); - if(Number.isNaN(number)) { - return 0; - } - - return number; -} - -function readThemeCssFiles() { - const bundlesPath = path.join(PACKAGE_ROOT, 'scss', 'bundles'); - const result = []; - - if(!fs.existsSync(bundlesPath)) { - return result; - } - - fs.readdirSync(bundlesPath, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .forEach((entry) => { - const bundleDirectory = path.join(bundlesPath, entry.name); - fs.readdirSync(bundleDirectory, { withFileTypes: true }) - .filter((file) => file.isFile() && file.name.endsWith('.scss')) - .forEach((file) => { - result.push(`${path.basename(file.name, '.scss')}.css`); - }); - }); - - return result; -} - -function readVectorMapTestData() { - if(!fs.existsSync(VECTOR_DATA_DIRECTORY)) { - return []; - } - - return fs.readdirSync(VECTOR_DATA_DIRECTORY, { withFileTypes: true }) - .filter((entry) => entry.isFile() && entry.name.endsWith('.txt')) - .map((entry) => { - const filePath = path.join(VECTOR_DATA_DIRECTORY, entry.name); - return { - name: path.basename(entry.name, '.txt'), - expected: fs.readFileSync(filePath, 'utf8'), - }; - }); -} - -async function redirectRequestToVectorMapNodeServer(action, arg) { - acquireVectorMapNodeServer(); - - try { - const startTime = Date.now(); - - while(true) { - try { - const text = await httpGetText(`http://127.0.0.1:${VECTOR_MAP_TESTER_PORT}/${action}/${arg}`); - return text; - } catch(error) { - if(Date.now() - startTime > 5000) { - throw error; - } - } - } - } finally { - releaseVectorMapNodeServer(); - } -} - -function acquireVectorMapNodeServer() { - if(vectorMapNodeServer.killTimer) { - clearTimeout(vectorMapNodeServer.killTimer); - vectorMapNodeServer.killTimer = null; - } - - if(!vectorMapNodeServer.process || vectorMapNodeServer.process.killed) { - const scriptPath = path.join(TESTING_ROOT, 'helpers', 'vectormaputils-tester.js'); - - vectorMapNodeServer.process = spawn( - PATH_TO_NODE, - [scriptPath, `${VECTOR_DATA_DIRECTORY}${path.sep}`], - { - stdio: 'ignore', - }, - ); - - vectorMapNodeServer.process.on('exit', () => { - if(vectorMapNodeServer.process && vectorMapNodeServer.process.exitCode !== null) { - vectorMapNodeServer.process = null; - } - }); - } - - vectorMapNodeServer.refs += 1; -} - -function releaseVectorMapNodeServer() { - vectorMapNodeServer.refs -= 1; - - if(vectorMapNodeServer.refs <= 0) { - vectorMapNodeServer.refs = 0; - - vectorMapNodeServer.killTimer = setTimeout(() => { - if(vectorMapNodeServer.refs === 0 && vectorMapNodeServer.process) { - try { - vectorMapNodeServer.process.kill(); - } catch(_) { - // Ignore process kill failures. - } - vectorMapNodeServer.process = null; - } - vectorMapNodeServer.killTimer = null; - }, 200); - } -} - -function executeVectorMapConsoleApp(arg, searchParams) { - const inputDirectory = `${path.join(PACKAGE_ROOT, 'testing', 'content', 'VectorMapData')}${path.sep}`; - const outputDirectory = path.join(inputDirectory, '__Output'); - const settingsPath = path.join(inputDirectory, '_settings.js'); - const processFileContentPath = path.join(inputDirectory, '_processFileContent.js'); - const vectorMapUtilsNodePath = path.resolve(path.join(PACKAGE_ROOT, 'artifacts/js/vectormap-utils/dx.vectormaputils.node.js')); - - const args = [vectorMapUtilsNodePath, inputDirectory]; - - if(searchParams.has('file')) { - args[1] += searchParams.get('file'); - } - - args.push('--quiet', '--output', outputDirectory, '--settings', settingsPath, '--process-file-content', processFileContentPath); - - const isJson = searchParams.has('json'); - - if(isJson) { - args.push('--json'); - } - - fs.mkdirSync(outputDirectory, { recursive: true }); - - try { - const spawnResult = spawnSync(PATH_TO_NODE, args, { - timeout: 15000, - stdio: 'ignore', - }); - - if(spawnResult.error && spawnResult.error.code === 'ETIMEDOUT') { - // Intentionally ignored to match legacy behavior. - } - - const extension = isJson ? '.json' : '.js'; - - return fs.readdirSync(outputDirectory, { withFileTypes: true }) - .filter((entry) => entry.isFile() && entry.name.endsWith(extension)) - .map((entry) => { - const filePath = path.join(outputDirectory, entry.name); - let text = fs.readFileSync(filePath, 'utf8'); - let variable = null; - - if(!isJson) { - const index = text.indexOf('='); - if(index > 0) { - variable = text.substring(0, index).trim(); - text = text.substring(index + 1).trim(); - - if(text.endsWith(';')) { - text = text.slice(0, -1).trim(); - } - } - } - - return { - file: `${path.basename(entry.name, extension)}${extension}`, - variable, - content: JSON.parse(text), - }; - }); - } finally { - try { - fs.rmSync(outputDirectory, { recursive: true, force: true }); - } catch(_) { - // Ignore cleanup errors. - } - } -} - -function tryServeStatic(req, res, pathname, searchParams) { - const normalizedPath = pathname === '/' ? '/' : pathname.replace(/\/+$/, ''); - const relativePath = normalizedPath.replace(/^\/+/, ''); - const filePath = path.resolve(path.join(REPO_ROOT, relativePath)); - const relativeToRoot = path.relative(REPO_ROOT, filePath); - - if(relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) { - setNoCacheHeaders(res); - res.statusCode = 403; - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.end('Forbidden'); - return true; - } - - if(!fs.existsSync(filePath)) { - return false; - } - - setStaticCacheHeaders(res, searchParams); - - const stat = fs.statSync(filePath); - - if(stat.isDirectory()) { - return sendDirectoryListing(res, pathname, filePath); - } - - if(stat.isFile()) { - return sendStaticFile(res, filePath, stat.size); - } - - return false; -} - -function sendStaticFile(res, filePath, fileSize) { - res.statusCode = 200; - res.setHeader('Content-Type', getContentType(filePath)); - res.setHeader('Content-Length', String(fileSize)); - - const stream = fs.createReadStream(filePath); - stream.pipe(res); - - stream.on('error', () => { - if(!res.headersSent) { - res.statusCode = 500; - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - } - if(!res.writableEnded) { - res.end('Internal Server Error'); - } - }); - - return true; -} - -function sendDirectoryListing(res, requestPath, dirPath) { - const entries = fs.readdirSync(dirPath, { withFileTypes: true }); - const pathname = requestPath.endsWith('/') ? requestPath : `${requestPath}/`; - - const items = []; - - if(pathname !== '/') { - const parentPath = pathname - .split('/') - .filter(Boolean) - .slice(0, -1) - .join('/'); - const href = parentPath ? `/${parentPath}/` : '/'; - items.push(`
  • ..
  • `); - } - - entries - .sort((a, b) => a.name.localeCompare(b.name)) - .forEach((entry) => { - const suffix = entry.isDirectory() ? '/' : ''; - const href = `${pathname}${encodeURIComponent(entry.name)}${suffix}`; - items.push(`
  • ${escapeHtml(entry.name)}${suffix}
  • `); - }); - - const html = ` - - - -Index of ${escapeHtml(pathname)} - - -

    Index of ${escapeHtml(pathname)}

    -
      -${items.join('\n')} -
    - -`; - - res.statusCode = 200; - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(html); - - return true; -} - -function readBodyText(req) { - return new Promise((resolve, reject) => { - const chunks = []; - - req.on('data', (chunk) => { - chunks.push(chunk); - }); - - req.on('end', () => { - resolve(Buffer.concat(chunks).toString('utf8')); - }); - - req.on('error', reject); - }); -} - -async function readFormBody(req) { - const body = await readBodyText(req); - return Object.fromEntries(new URLSearchParams(body)); -} - -function sendHtml(res, html) { - setNoCacheHeaders(res); - res.statusCode = 200; - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(html); -} - -function sendJson(res, payload) { - setNoCacheHeaders(res); - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.end(JSON.stringify(payload)); -} - -function sendJsonText(res, payloadText) { - setNoCacheHeaders(res); - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.end(payloadText); -} - -function sendXml(res, payload) { - setNoCacheHeaders(res); - res.statusCode = 200; - res.setHeader('Content-Type', 'text/xml; charset=utf-8'); - res.end(payload); -} - -function sendText(res, payload) { - setNoCacheHeaders(res); - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.end(payload); -} - -function sendNotFound(res) { - setNoCacheHeaders(res); - res.statusCode = 404; - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.end('Not Found'); -} - -function setNoCacheHeaders(res) { - res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); - res.setHeader('Pragma', 'no-cache'); - res.setHeader('Expires', '0'); -} - -function setStaticCacheHeaders(res, searchParams) { - if(searchParams.has('DX_HTTP_CACHE')) { - res.setHeader('Cache-Control', 'public, max-age=31536000'); - } else { - res.setHeader('Cache-Control', 'private, must-revalidate, max-age=0'); - } -} - -function getCacheBuster(searchParams) { - if(searchParams.has('DX_HTTP_CACHE')) { - const value = searchParams.get('DX_HTTP_CACHE') || ''; - return `DX_HTTP_CACHE=${encodeURIComponent(value)}`; - } - - return ''; -} - -function contentWithCacheBuster(contentPath, cacheBuster) { - if(!cacheBuster) { - return contentPath; - } - - return `${contentPath}${contentPath.includes('?') ? '&' : '?'}${cacheBuster}`; -} - -function getContentType(filePath) { - const ext = path.extname(filePath).toLowerCase(); - - switch(ext) { - case '.html': - case '.htm': - return 'text/html; charset=utf-8'; - case '.css': - return 'text/css; charset=utf-8'; - case '.js': - case '.mjs': - return 'application/javascript; charset=utf-8'; - case '.json': - return 'application/json; charset=utf-8'; - case '.xml': - case '.xsl': - return 'text/xml; charset=utf-8'; - case '.txt': - case '.md': - case '.log': - return 'text/plain; charset=utf-8'; - case '.svg': - return 'image/svg+xml'; - case '.png': - return 'image/png'; - case '.jpg': - case '.jpeg': - return 'image/jpeg'; - case '.gif': - return 'image/gif'; - case '.ico': - return 'image/x-icon'; - case '.woff': - return 'font/woff'; - case '.woff2': - return 'font/woff2'; - case '.ttf': - return 'font/ttf'; - case '.eot': - return 'application/vnd.ms-fontobject'; - case '.map': - return 'application/json; charset=utf-8'; - case '.wasm': - return 'application/wasm'; - default: - return 'application/octet-stream'; - } -} - -function jsonString(value) { - return JSON.stringify(value); -} - -function renderTemplate(templateName, vars) { - const template = readTemplate(templateName); - const data = vars || {}; - - return template - .replace(/\{\{\{([A-Za-z0-9_]+)\}\}\}/g, (_, key) => getTemplateValue(data, key, false)) - .replace(/\{\{([A-Za-z0-9_]+)\}\}/g, (_, key) => getTemplateValue(data, key, true)); -} - -function readTemplate(templateName) { - const key = String(templateName || ''); - - if(TEMPLATE_CACHE.has(key)) { - return TEMPLATE_CACHE.get(key); - } - - const filePath = path.resolve(TEMPLATES_ROOT, key); - const relativePath = path.relative(TEMPLATES_ROOT, filePath); - - if(relativePath.startsWith('..') || path.isAbsolute(relativePath)) { - throw new Error(`Invalid template path: ${key}`); - } - - const templateText = fs.readFileSync(filePath, 'utf8'); - TEMPLATE_CACHE.set(key, templateText); - - return templateText; -} - -function getTemplateValue(data, key, shouldEscape) { - const hasValue = Object.prototype.hasOwnProperty.call(data, key); - const value = hasValue ? data[key] : ''; - const valueAsString = value === null || value === undefined ? '' : String(value); - - if(shouldEscape) { - return escapeHtml(valueAsString); - } - - return valueAsString; -} - -function escapeHtml(value) { - return String(value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -function escapeXmlText(value) { - return String(value) - .replace(/&/g, '&') - .replace(//g, '>'); -} - -function escapeXmlAttr(value) { - return escapeXmlText(value) - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -function loadPorts(filePath) { - return JSON.parse(fs.readFileSync(filePath, 'utf8')); -} - -function safeReadFile(filePath) { - try { - return fs.readFileSync(filePath, 'utf8'); - } catch(_) { - return ''; - } -} - -function parseBoolean(value) { - return String(value).toLowerCase() === 'true'; -} - -function parseNumber(value) { - const number = Number(value); - return Number.isNaN(number) ? 0 : number; -} - -function splitCommaList(value) { - return value - .split(',') - .map((item) => item.trim()) - .filter(Boolean); -} - -function safeDecodeURIComponent(value) { - try { - return decodeURIComponent(value); - } catch(_) { - return value; - } -} - function writeLastSuiteTime() { fs.writeFileSync(LAST_SUITE_TIME_FILENAME, formatDateForSuiteTimestamp(new Date()), 'utf8'); } -function formatDateForSuiteTimestamp(date) { - return [ - date.getFullYear(), - pad2(date.getMonth() + 1), - pad2(date.getDate()), - ].join('-') + 'T' + [ - pad2(date.getHours()), - pad2(date.getMinutes()), - pad2(date.getSeconds()), - ].join(':'); -} - -function isContinuousIntegration() { - return Boolean(process.env.CCNetWorkingDirectory || process.env.DEVEXTREME_TEST_CI); -} - -function resolveNodePath() { - if(process.env.CCNetWorkingDirectory) { - const customPath = path.join(process.env.CCNetWorkingDirectory, 'node', 'node.exe'); - if(fs.existsSync(customPath)) { - return customPath; - } - } - - return 'node'; -} - function logMiscErrorCore(data) { if(!RUN_FLAGS.isContinuousIntegration) { return; @@ -1456,95 +444,3 @@ function logMiscErrorCore(data) { // Ignore logging errors. } } - -function createRawLogger(filePath) { - return { - filePath, - writeLine(text = '') { - this.write(`${text || ''}\r\n`); - this._time = true; - }, - write(text = '') { - if(!text) { - return; - } - - if(this._time !== false) { - this._time = false; - fs.appendFileSync(this.filePath, `${formatLogTime(new Date())} `, 'utf8'); - } - - fs.appendFileSync(this.filePath, text, 'utf8'); - }, - _time: true, - }; -} - -function formatLogTime(date) { - let hours = date.getHours() % 12; - if(hours === 0) { - hours = 12; - } - - return `${pad2(hours)}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`; -} - -function write(message, color) { - const text = String(message || ''); - logger.write(text); - process.stdout.write(colorize(text, color)); -} - -function writeLine(message = '', color) { - const text = String(message || ''); - logger.writeLine(text); - process.stdout.write(`${colorize(text, color)}\n`); -} - -function writeError(message) { - const text = `ERROR: ${message}`; - logger.writeLine(text); - process.stderr.write(`${text}\n`); -} - -function colorize(text, color) { - if(!color) { - return text; - } - - const colorCodes = { - red: 31, - green: 32, - yellow: 33, - white: 37, - }; - - const code = colorCodes[color]; - if(!code) { - return text; - } - - return `\u001b[${code}m${text}\u001b[0m`; -} - -function pad2(value) { - return String(value).padStart(2, '0'); -} - -function httpGetText(targetUrl) { - return new Promise((resolve, reject) => { - const request = http.get(targetUrl, (response) => { - const chunks = []; - - response.on('data', (chunk) => { - chunks.push(chunk); - }); - - response.on('end', () => { - resolve(Buffer.concat(chunks).toString('utf8')); - }); - }); - - request.on('error', reject); - }); -} diff --git a/packages/devextreme/testing/runner/lib/http.js b/packages/devextreme/testing/runner/lib/http.js new file mode 100644 index 000000000000..eb04bedbba85 --- /dev/null +++ b/packages/devextreme/testing/runner/lib/http.js @@ -0,0 +1,66 @@ +function setNoCacheHeaders(res) { + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); +} + +function setStaticCacheHeaders(res, searchParams) { + if(searchParams.has('DX_HTTP_CACHE')) { + res.setHeader('Cache-Control', 'public, max-age=31536000'); + } else { + res.setHeader('Cache-Control', 'private, must-revalidate, max-age=0'); + } +} + +function sendHtml(res, html) { + setNoCacheHeaders(res); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(html); +} + +function sendJson(res, payload) { + setNoCacheHeaders(res); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify(payload)); +} + +function sendJsonText(res, payloadText) { + setNoCacheHeaders(res); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(payloadText); +} + +function sendXml(res, payload) { + setNoCacheHeaders(res); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/xml; charset=utf-8'); + res.end(payload); +} + +function sendText(res, payload) { + setNoCacheHeaders(res); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end(payload); +} + +function sendNotFound(res) { + setNoCacheHeaders(res); + res.statusCode = 404; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end('Not Found'); +} + +module.exports = { + sendHtml, + sendJson, + sendJsonText, + sendNotFound, + sendText, + sendXml, + setNoCacheHeaders, + setStaticCacheHeaders, +}; diff --git a/packages/devextreme/testing/runner/lib/logger.js b/packages/devextreme/testing/runner/lib/logger.js new file mode 100644 index 000000000000..9e9636af7a1f --- /dev/null +++ b/packages/devextreme/testing/runner/lib/logger.js @@ -0,0 +1,83 @@ +const fs = require('fs'); + +function createRunnerLogger(filePath) { + const rawLogger = createRawLogger(filePath); + + return { + write(message, color) { + const text = String(message || ''); + rawLogger.write(text); + process.stdout.write(colorize(text, color)); + }, + writeLine(message = '', color) { + const text = String(message || ''); + rawLogger.writeLine(text); + process.stdout.write(`${colorize(text, color)}\n`); + }, + writeError(message) { + const text = `ERROR: ${message}`; + rawLogger.writeLine(text); + process.stderr.write(`${text}\n`); + }, + }; +} + +function createRawLogger(filePath) { + return { + filePath, + writeLine(text = '') { + this.write(`${text || ''}\r\n`); + this._time = true; + }, + write(text = '') { + if(!text) { + return; + } + + if(this._time !== false) { + this._time = false; + fs.appendFileSync(this.filePath, `${formatLogTime(new Date())} `, 'utf8'); + } + + fs.appendFileSync(this.filePath, text, 'utf8'); + }, + _time: true, + }; +} + +function formatLogTime(date) { + let hours = date.getHours() % 12; + if(hours === 0) { + hours = 12; + } + + return `${pad2(hours)}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`; +} + +function pad2(value) { + return String(value).padStart(2, '0'); +} + +function colorize(text, color) { + if(!color) { + return text; + } + + const colorCodes = { + red: 31, + green: 32, + yellow: 33, + white: 37, + }; + + const code = colorCodes[color]; + if(!code) { + return text; + } + + return `\u001b[${code}m${text}\u001b[0m`; +} + +module.exports = { + createRunnerLogger, +}; diff --git a/packages/devextreme/testing/runner/lib/pages.js b/packages/devextreme/testing/runner/lib/pages.js new file mode 100644 index 000000000000..6abdc9b7dd91 --- /dev/null +++ b/packages/devextreme/testing/runner/lib/pages.js @@ -0,0 +1,247 @@ +function createPagesRenderer({ + contentWithCacheBuster, + getCacheBuster, + jsonString, + renderTemplate, +}) { + function renderIndexPage() { + return renderTemplate('index.template.html', { + JQUERY_URL: '/packages/devextreme/artifacts/js/jquery.js', + KNOCKOUT_URL: '/packages/devextreme/artifacts/js/knockout-latest.js', + ROOT_URL_JSON: jsonString('/'), + SUITES_JSON_URL_JSON: jsonString('/Main/SuitesJson'), + CATEGORIES_JSON_URL_JSON: jsonString('/Main/CategoriesJson'), + }); + } + + function renderRunAllPage(model, runProps) { + return renderTemplate('run-all.template.html', { + JQUERY_URL: '/packages/devextreme/artifacts/js/jquery.js', + CONSTELLATION_JSON: jsonString(model.Constellation), + CATEGORIES_LIST_JSON: jsonString(model.CategoriesList), + VERSION_JSON: jsonString(model.Version), + SUITES_JSON: jsonString(model.Suites), + NO_TRY_CATCH_JSON: jsonString(runProps.NoTryCatch), + NO_GLOBALS_JSON: jsonString(runProps.NoGlobals), + NO_TIMERS_JSON: jsonString(runProps.NoTimers), + NO_JQUERY_JSON: jsonString(runProps.NoJQuery), + SHADOW_DOM_JSON: jsonString(runProps.ShadowDom), + NO_CSP_JSON: jsonString(runProps.NoCsp), + IS_CONTINUOUS_INTEGRATION_JSON: jsonString(runProps.IsContinuousIntegration), + WORKER_IN_WINDOW_JSON: jsonString(runProps.WorkerInWindow), + MAX_WORKERS_JSON: jsonString(runProps.MaxWorkers), + }); + } + + function renderRunSuitePage(model, runProps, searchParams) { + const scriptVirtualPath = model.ScriptVirtualPath; + const isNoJQueryTest = scriptVirtualPath.includes('nojquery'); + const isServerSideTest = scriptVirtualPath.includes('DevExpress.serverSide'); + const isSelfSufficientTest = scriptVirtualPath.includes('_bundled') + || scriptVirtualPath.includes('Bundles') + || scriptVirtualPath.includes('DevExpress.jquery'); + + const cspPart = runProps.NoCsp ? '' : '-systemjs'; + const npmModule = `transpiled${cspPart}`; + const testingBasePath = runProps.NoCsp + ? '/packages/devextreme/testing/' + : '/packages/devextreme/artifacts/transpiled-testing/'; + + function getJQueryUrl() { + if(isNoJQueryTest) { + return `${testingBasePath}helpers/noJQuery.js`; + } + + return '/packages/devextreme/artifacts/js/jquery.js'; + } + + function getTestUrl() { + if(runProps.NoCsp) { + return scriptVirtualPath; + } + + return scriptVirtualPath.replace('/testing/', '/artifacts/transpiled-testing/'); + } + + function getJQueryIntegrationImports() { + const result = []; + + if(!isSelfSufficientTest) { + if(runProps.NoJQuery || isNoJQueryTest || isServerSideTest) { + result.push(`${testingBasePath}helpers/jQueryEventsPatch.js`); + result.push(`${testingBasePath}helpers/argumentsValidator.js`); + result.push(`${testingBasePath}helpers/dataPatch.js`); + result.push(`/packages/devextreme/artifacts/${npmModule}/__internal/integration/jquery/component_registrator.js`); + } else { + result.push(`/packages/devextreme/artifacts/${npmModule}/integration/jquery.js`); + } + } + + if(isServerSideTest) { + result.push(`${testingBasePath}helpers/ssrEmulator.js`); + } + + return result; + } + + const cacheBuster = getCacheBuster(searchParams); + + const qunitCss = contentWithCacheBuster('/packages/devextreme/node_modules/qunit/qunit/qunit.css', cacheBuster); + const qunitJs = contentWithCacheBuster('/packages/devextreme/node_modules/qunit/qunit/qunit.js', cacheBuster); + 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 cspMap = !runProps.NoCsp + ? { + 'inferno-create-element': '/packages/devextreme/node_modules/inferno-create-element/dist/inferno-create-element.js', + intl: '/packages/devextreme/artifacts/js-systemjs/intl/index.js', + knockout: '/packages/devextreme/artifacts/js-systemjs/knockout.js', + css: '/packages/devextreme/artifacts/js-systemjs/css.js', + 'generic_light.css': '/packages/devextreme/artifacts/css-systemjs/dx.light.css', + 'material_blue_light.css': '/packages/devextreme/artifacts/css-systemjs/dx.material.blue.light.css', + 'fluent_blue_light.css': '/packages/devextreme/artifacts/css-systemjs/dx.fluent.blue.light.css', + 'gantt.css': '/packages/devextreme/artifacts/css-systemjs/dx-gantt.css', + 'devextreme-cldr-data': '/packages/devextreme/artifacts/js-systemjs/devextreme-cldr-data', + 'cldr-core': '/packages/devextreme/artifacts/js-systemjs/cldr-core', + json: '/packages/devextreme/artifacts/js-systemjs/json.js', + '@preact/signals-core': '/packages/devextreme/artifacts/js-systemjs/preact-signals.js', + } + : { + 'devextreme-cldr-data': '/packages/devextreme/node_modules/devextreme-cldr-data', + 'cldr-core': '/packages/devextreme/node_modules/cldr-core', + '@preact/signals-core': '/packages/devextreme/node_modules/@preact/signals-core/dist/signals-core.js', + }; + + const systemMap = { + globalize: '/packages/devextreme/node_modules/globalize/dist/globalize', + intl: '/packages/devextreme/node_modules/intl/index.js', + cldr: '/packages/devextreme/node_modules/cldrjs/dist/cldr', + jquery: getJQueryUrl(), + knockout: '/packages/devextreme/node_modules/knockout/build/output/knockout-latest.debug.js', + jszip: '/packages/devextreme/artifacts/js/jszip.js', + underscore: '/packages/devextreme/node_modules/underscore/underscore-min.js', + '@@devextreme/vdom': '/packages/devextreme/node_modules/@devextreme/vdom', + 'devextreme-quill': '/packages/devextreme/node_modules/devextreme-quill/dist/dx-quill.js', + 'devexpress-diagram': '/packages/devextreme/artifacts/js/dx-diagram.js', + 'devexpress-gantt': '/packages/devextreme/artifacts/js/dx-gantt.js', + 'devextreme-exceljs-fork': '/packages/devextreme/node_modules/devextreme-exceljs-fork/dist/dx-exceljs-fork.js', + 'fflate': '/packages/devextreme/node_modules/fflate/esm/browser.js', + jspdf: '/packages/devextreme/node_modules/jspdf/dist/jspdf.umd.js', + 'jspdf-autotable': '/packages/devextreme/node_modules/jspdf-autotable/dist/jspdf.plugin.autotable.js', + rrule: '/packages/devextreme/node_modules/rrule/dist/es5/rrule.js', + inferno: '/packages/devextreme/node_modules/inferno/dist/inferno.js', + 'inferno-hydrate': '/packages/devextreme/node_modules/inferno-hydrate/dist/inferno-hydrate.js', + 'inferno-compat': '/packages/devextreme/node_modules/inferno-compat/dist/inferno-compat.js', + 'inferno-clone-vnode': '/packages/devextreme/node_modules/inferno-clone-vnode/dist/index.cjs.js', + 'inferno-create-element': '/packages/devextreme/node_modules/inferno-create-element/dist/index.cjs.js', + 'inferno-create-class': '/packages/devextreme/node_modules/inferno-create-class/dist/index.cjs.js', + 'inferno-extras': '/packages/devextreme/node_modules/inferno-extras/dist/index.cjs.js', + 'generic_light.css': '/packages/devextreme/artifacts/css/dx.light.css', + 'material_blue_light.css': '/packages/devextreme/artifacts/css/dx.material.blue.light.css', + 'fluent_blue_light.css': '/packages/devextreme/artifacts/css/dx.fluent.blue.light.css', + 'gantt.css': '/packages/devextreme/artifacts/css/dx-gantt.css', + css: '/packages/devextreme/node_modules/systemjs-plugin-css/css.js', + text: '/packages/devextreme/node_modules/systemjs-plugin-text/text.js', + json: '/packages/devextreme/node_modules/systemjs-plugin-json/json.js', + 'plugin-babel': '/packages/devextreme/node_modules/systemjs-plugin-babel/plugin-babel.js', + 'systemjs-babel-build': '/packages/devextreme/node_modules/systemjs-plugin-babel/systemjs-babel-browser.js', + ...cspMap, + }; + + const systemPackages = { + '': { + defaultExtension: 'js', + }, + globalize: { + main: '../globalize.js', + defaultExtension: 'js', + }, + cldr: { + main: '../cldr.js', + defaultExtension: 'js', + }, + 'common/core/events/utils': { + main: 'index', + }, + 'events/utils': { + main: 'index', + }, + events: { + main: 'index', + }, + }; + + const knockoutPath = '/packages/devextreme/node_modules/knockout/build/output/knockout-latest.debug.js'; + + const systemConfig = { + baseURL: `/packages/devextreme/artifacts/${npmModule}`, + transpiler: 'plugin-babel', + map: systemMap, + packages: systemPackages, + packageConfigPaths: [ + '@@devextreme/*/package.json', + ], + meta: { + [knockoutPath]: { + format: 'global', + deps: ['jquery'], + exports: 'ko', + }, + '*.js': { + babelOptions: { + es2015: false, + }, + }, + }, + }; + + const integrationImportPaths = getJQueryIntegrationImports(); + const cspMetaTag = runProps.NoCsp + ? '' + : ``; + + return renderTemplate('run-suite.template.html', { + CSP_META_TAG: cspMetaTag, + TITLE: model.Title, + QUNIT_CSS_URL: qunitCss, + QUNIT_JS_URL: qunitJs, + QUNIT_EXTENSIONS_JS_URL: qunitExtensionsJs, + JQUERY_JS_URL: jqueryJs, + SINON_JS_URL: sinonJs, + SYSTEM_JS_URL: systemJs, + IS_CONTINUOUS_INTEGRATION_JSON: jsonString(runProps.IsContinuousIntegration), + CACHE_BUSTER_JSON: jsonString(cacheBuster), + SYSTEM_CONFIG_JSON: jsonString(systemConfig), + INTEGRATION_IMPORT_PATHS_JSON: jsonString(integrationImportPaths), + IS_SERVER_SIDE_TEST_JSON: jsonString(isServerSideTest), + TEST_URL_JSON: jsonString(getTestUrl()), + }); + } + + return { + renderIndexPage, + renderRunAllPage, + renderRunSuitePage, + }; +} + +module.exports = { + createPagesRenderer, +}; diff --git a/packages/devextreme/testing/runner/lib/results.js b/packages/devextreme/testing/runner/lib/results.js new file mode 100644 index 000000000000..cdee8262fa91 --- /dev/null +++ b/packages/devextreme/testing/runner/lib/results.js @@ -0,0 +1,161 @@ +function createResultsReporter({ + escapeXmlAttr, + escapeXmlText, + normalizeNumber, +}) { + function validateResultsJson(json) { + const badToken = '\\u0000'; + const badIndex = json.indexOf(badToken); + + if(badIndex > -1) { + const from = Math.max(0, badIndex - 200); + const to = Math.min(json.length, badIndex + 200); + throw new Error(`Result JSON has bad content: ${json.slice(from, to)}`); + } + } + + function printTextReport(results, writeLine) { + const maxWrittenFailures = 50; + const notRunCases = []; + const failedCases = []; + + (results.suites || []).forEach((suite) => { + enumerateAllCases(suite, (testCase) => { + if(testCase && testCase.reason) { + notRunCases.push(testCase); + } + if(testCase && testCase.failure) { + failedCases.push(testCase); + } + }); + }); + + const total = Number(results.total) || 0; + const failures = Number(results.failures) || 0; + const notRunCount = notRunCases.length; + const color = failures > 0 ? 'red' : (notRunCount > 0 ? 'yellow' : 'green'); + + writeLine(`Tests run: ${total}, Failures: ${failures}, Not run: ${notRunCount}`, color); + + if(notRunCount > 0 && failures === 0) { + notRunCases.forEach((testCase) => { + writeLine('-'.repeat(80)); + writeLine(`Skipped: ${testCase.name || ''}`); + writeLine(`Reason: ${testCase.reason && testCase.reason.message ? testCase.reason.message : ''}`); + }); + } + + if(failures > 0) { + let writtenFailures = 0; + + failedCases.forEach((testCase) => { + if(writtenFailures >= maxWrittenFailures) { + return; + } + + writeLine('-'.repeat(80)); + writeLine(testCase.name || '', 'white'); + writeLine(); + writeLine(testCase.failure && testCase.failure.message ? testCase.failure.message : ''); + + writtenFailures += 1; + }); + + if(writtenFailures >= maxWrittenFailures) { + writeLine(`WARNING: only first ${maxWrittenFailures} failures are shown.`); + } + } + } + + function testResultsToXml(results) { + const lines = []; + + lines.push(``); + + (results.suites || []).forEach((suite) => { + lines.push(renderSuiteXml(suite, ' ')); + }); + + lines.push(''); + + return `${lines.join('\n')}\n`; + } + + function renderSuiteXml(suite, indent) { + const lines = []; + + lines.push(`${indent}`); + lines.push(`${indent} `); + + (suite.results || []).forEach((item) => { + if(item && Array.isArray(item.results)) { + lines.push(renderSuiteXml(item, `${indent} `)); + } else { + lines.push(renderCaseXml(item || {}, `${indent} `)); + } + }); + + lines.push(`${indent} `); + lines.push(`${indent}`); + + return lines.join('\n'); + } + + function renderCaseXml(testCase, indent) { + const attributes = [ + `name="${escapeXmlAttr(testCase.name || '')}"`, + `url="${escapeXmlAttr(testCase.url || '')}"`, + `time="${escapeXmlAttr(testCase.time || '')}"`, + ]; + + if(testCase.executed === false) { + attributes.push('executed="false"'); + } + + const hasFailure = Boolean(testCase.failure && typeof testCase.failure.message === 'string'); + const hasReason = Boolean(testCase.reason && typeof testCase.reason.message === 'string'); + + if(!hasFailure && !hasReason) { + return `${indent}`; + } + + const lines = [`${indent}`]; + + if(hasFailure) { + lines.push(`${indent} `); + lines.push(`${indent} ${escapeXmlText(testCase.failure.message)}`); + lines.push(`${indent} `); + } + + if(hasReason) { + lines.push(`${indent} `); + lines.push(`${indent} ${escapeXmlText(testCase.reason.message)}`); + lines.push(`${indent} `); + } + + lines.push(`${indent}`); + + return lines.join('\n'); + } + + return { + printTextReport, + testResultsToXml, + validateResultsJson, + }; +} + +function enumerateAllCases(suite, callback) { + (suite.results || []).forEach((item) => { + if(item && Array.isArray(item.results)) { + enumerateAllCases(item, callback); + return; + } + + callback(item); + }); +} + +module.exports = { + createResultsReporter, +}; diff --git a/packages/devextreme/testing/runner/lib/static.js b/packages/devextreme/testing/runner/lib/static.js new file mode 100644 index 000000000000..d904245222bc --- /dev/null +++ b/packages/devextreme/testing/runner/lib/static.js @@ -0,0 +1,165 @@ +const fs = require('fs'); +const path = require('path'); + +function createStaticFileService({ + escapeHtml, + rootDirectory, + setNoCacheHeaders, + setStaticCacheHeaders, +}) { + function tryServeStatic(req, res, pathname, searchParams) { + const normalizedPath = pathname === '/' ? '/' : pathname.replace(/\/+$/, ''); + const relativePath = normalizedPath.replace(/^\/+/, ''); + const filePath = path.resolve(path.join(rootDirectory, relativePath)); + const relativeToRoot = path.relative(rootDirectory, filePath); + + if(relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) { + setNoCacheHeaders(res); + res.statusCode = 403; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end('Forbidden'); + return true; + } + + if(!fs.existsSync(filePath)) { + return false; + } + + setStaticCacheHeaders(res, searchParams); + + const stat = fs.statSync(filePath); + + if(stat.isDirectory()) { + return sendDirectoryListing(res, pathname, filePath); + } + + if(stat.isFile()) { + return sendStaticFile(res, filePath, stat.size); + } + + return false; + } + + function sendStaticFile(res, filePath, fileSize) { + res.statusCode = 200; + res.setHeader('Content-Type', getContentType(filePath)); + res.setHeader('Content-Length', String(fileSize)); + + const stream = fs.createReadStream(filePath); + stream.pipe(res); + + stream.on('error', () => { + if(!res.headersSent) { + res.statusCode = 500; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + } + if(!res.writableEnded) { + res.end('Internal Server Error'); + } + }); + + return true; + } + + function sendDirectoryListing(res, requestPath, dirPath) { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + const pathname = requestPath.endsWith('/') ? requestPath : `${requestPath}/`; + + const items = []; + + if(pathname !== '/') { + const parentPath = pathname + .split('/') + .filter(Boolean) + .slice(0, -1) + .join('/'); + const href = parentPath ? `/${parentPath}/` : '/'; + items.push(`
  • ..
  • `); + } + + entries + .sort((a, b) => a.name.localeCompare(b.name)) + .forEach((entry) => { + const suffix = entry.isDirectory() ? '/' : ''; + const href = `${pathname}${encodeURIComponent(entry.name)}${suffix}`; + items.push(`
  • ${escapeHtml(entry.name)}${suffix}
  • `); + }); + + const html = ` + + + +Index of ${escapeHtml(pathname)} + + +

    Index of ${escapeHtml(pathname)}

    +
      +${items.join('\n')} +
    + +`; + + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(html); + + return true; + } + + return { + tryServeStatic, + }; +} + +function getContentType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + + switch(ext) { + case '.html': + case '.htm': + return 'text/html; charset=utf-8'; + case '.css': + return 'text/css; charset=utf-8'; + case '.js': + case '.mjs': + return 'application/javascript; charset=utf-8'; + case '.json': + return 'application/json; charset=utf-8'; + case '.xml': + case '.xsl': + return 'text/xml; charset=utf-8'; + case '.txt': + case '.md': + case '.log': + return 'text/plain; charset=utf-8'; + case '.svg': + return 'image/svg+xml'; + case '.png': + return 'image/png'; + case '.jpg': + case '.jpeg': + return 'image/jpeg'; + case '.gif': + return 'image/gif'; + case '.ico': + return 'image/x-icon'; + case '.woff': + return 'font/woff'; + case '.woff2': + return 'font/woff2'; + case '.ttf': + return 'font/ttf'; + case '.eot': + return 'application/vnd.ms-fontobject'; + case '.map': + return 'application/json; charset=utf-8'; + case '.wasm': + return 'application/wasm'; + default: + return 'application/octet-stream'; + } +} + +module.exports = { + createStaticFileService, +}; diff --git a/packages/devextreme/testing/runner/lib/suites.js b/packages/devextreme/testing/runner/lib/suites.js new file mode 100644 index 000000000000..f046d2154644 --- /dev/null +++ b/packages/devextreme/testing/runner/lib/suites.js @@ -0,0 +1,156 @@ +const fs = require('fs'); +const path = require('path'); + +function createSuitesService({ + knownConstellations, + testsRoot, +}) { + function readCategories() { + const dirs = fs.readdirSync(testsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(testsRoot, entry.name)) + .filter(isNotEmptyDir) + .map(categoryFromPath) + .sort((a, b) => a.Name.localeCompare(b.Name)); + + return dirs; + } + + function readSuites(catName) { + if(!catName) { + throw new Error('Category name is required.'); + } + + const catPath = path.join(testsRoot, catName); + + const subDirs = fs.readdirSync(catPath, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + + subDirs.forEach((dirName) => { + if(!dirName.endsWith('Parts')) { + throw new Error(`Unexpected sub-directory in the test category: ${path.join(catPath, dirName)}`); + } + }); + + const suites = fs.readdirSync(catPath, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.js')) + .map((entry) => suiteFromPath(catName, path.join(catPath, entry.name))) + .sort((a, b) => a.ShortName.localeCompare(b.ShortName)); + + return suites; + } + + function getAllSuites({ + deviceMode, + constellation, + includeCategories, + excludeCategories, + excludeSuites, + partIndex, + partCount, + }) { + const includeSpecified = includeCategories && includeCategories.size > 0; + const excludeSpecified = excludeCategories && excludeCategories.size > 0; + const result = []; + + readCategories().forEach((category) => { + if(deviceMode && !category.RunOnDevices) { + return; + } + + if(constellation && category.Constellation !== constellation) { + return; + } + + if(includeSpecified && !includeCategories.has(category.Name)) { + return; + } + + if(category.Explicit && (!includeSpecified || !includeCategories.has(category.Name))) { + return; + } + + if(excludeSpecified && excludeCategories.has(category.Name)) { + return; + } + + let index = 0; + readSuites(category.Name).forEach((suite) => { + if(partCount > 1 && (index % partCount) !== partIndex) { + index += 1; + return; + } + + index += 1; + + if(excludeSuites && excludeSuites.has(suite.FullName)) { + return; + } + + result.push(suite); + }); + }); + + return result; + } + + function buildRunSuiteModel(catName, suiteName) { + return { + Title: suiteName, + ScriptVirtualPath: getSuiteVirtualPath(catName, suiteName), + }; + } + + function getSuiteVirtualPath(catName, suiteName) { + return `/packages/devextreme/testing/tests/${catName}/${suiteName}`; + } + + function categoryFromPath(categoryPath) { + const name = path.basename(categoryPath); + const metaPath = path.join(categoryPath, '__meta.json'); + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); + const constellation = String(meta.constellation || ''); + + if(!knownConstellations.has(constellation)) { + throw new Error(`Unknown constellation (group of categories):${constellation}`); + } + + return { + Name: name, + Constellation: constellation, + Explicit: Boolean(meta.explicit), + RunOnDevices: Boolean(meta.runOnDevices), + }; + } + + function suiteFromPath(catName, suitePath) { + const suiteName = path.basename(suitePath); + const shortName = path.basename(suitePath, '.js'); + + return { + ShortName: shortName, + FullName: `${catName}/${suiteName}`, + Url: `/run/${encodeURIComponent(catName)}/${encodeURIComponent(suiteName)}`, + }; + } + + return { + buildRunSuiteModel, + getAllSuites, + readCategories, + readSuites, + }; +} + +function isNotEmptyDir(dirPath) { + try { + return fs.readdirSync(dirPath).length > 0; + } catch(_) { + return false; + } +} + +module.exports = { + createSuitesService, +}; diff --git a/packages/devextreme/testing/runner/lib/templates.js b/packages/devextreme/testing/runner/lib/templates.js new file mode 100644 index 000000000000..a4d25453a766 --- /dev/null +++ b/packages/devextreme/testing/runner/lib/templates.js @@ -0,0 +1,55 @@ +const fs = require('fs'); +const path = require('path'); + +function createTemplateRenderer(templatesRoot, escapeHtml) { + const templateCache = new Map(); + + function readTemplate(templateName) { + const key = String(templateName || ''); + + if(templateCache.has(key)) { + return templateCache.get(key); + } + + const filePath = path.resolve(templatesRoot, key); + const relativePath = path.relative(templatesRoot, filePath); + + if(relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + throw new Error(`Invalid template path: ${key}`); + } + + const templateText = fs.readFileSync(filePath, 'utf8'); + templateCache.set(key, templateText); + + return templateText; + } + + function getTemplateValue(data, key, shouldEscape) { + const hasValue = Object.prototype.hasOwnProperty.call(data, key); + const value = hasValue ? data[key] : ''; + const valueAsString = value === null || value === undefined ? '' : String(value); + + if(shouldEscape) { + return escapeHtml(valueAsString); + } + + return valueAsString; + } + + function renderTemplate(templateName, vars) { + const template = readTemplate(templateName); + const data = vars || {}; + + return template + .replace(/\{\{\{([A-Za-z0-9_]+)\}\}\}/g, (_, key) => getTemplateValue(data, key, false)) + .replace(/\{\{([A-Za-z0-9_]+)\}\}/g, (_, key) => getTemplateValue(data, key, true)); + } + + return { + renderTemplate, + }; +} + +module.exports = { + createTemplateRenderer, +}; diff --git a/packages/devextreme/testing/runner/lib/utils.js b/packages/devextreme/testing/runner/lib/utils.js new file mode 100644 index 000000000000..6c60b9cd565f --- /dev/null +++ b/packages/devextreme/testing/runner/lib/utils.js @@ -0,0 +1,164 @@ +const fs = require('fs'); +const path = require('path'); + +function jsonString(value) { + return JSON.stringify(value); +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function escapeXmlText(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>'); +} + +function escapeXmlAttr(value) { + return escapeXmlText(value) + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function loadPorts(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function safeReadFile(filePath) { + try { + return fs.readFileSync(filePath, 'utf8'); + } catch(_) { + return ''; + } +} + +function parseBoolean(value) { + return String(value).toLowerCase() === 'true'; +} + +function parseNumber(value) { + const number = Number(value); + return Number.isNaN(number) ? 0 : number; +} + +function splitCommaList(value) { + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function safeDecodeURIComponent(value) { + try { + return decodeURIComponent(value); + } catch(_) { + return value; + } +} + +function pad2(value) { + return String(value).padStart(2, '0'); +} + +function formatDateForSuiteTimestamp(date) { + return [ + date.getFullYear(), + pad2(date.getMonth() + 1), + pad2(date.getDate()), + ].join('-') + 'T' + [ + pad2(date.getHours()), + pad2(date.getMinutes()), + pad2(date.getSeconds()), + ].join(':'); +} + +function isContinuousIntegration() { + return Boolean(process.env.CCNetWorkingDirectory || process.env.DEVEXTREME_TEST_CI); +} + +function resolveNodePath() { + if(process.env.CCNetWorkingDirectory) { + const customPath = path.join(process.env.CCNetWorkingDirectory, 'node', 'node.exe'); + if(fs.existsSync(customPath)) { + return customPath; + } + } + + return 'node'; +} + +function readBodyText(req) { + return new Promise((resolve, reject) => { + const chunks = []; + + req.on('data', (chunk) => { + chunks.push(chunk); + }); + + req.on('end', () => { + resolve(Buffer.concat(chunks).toString('utf8')); + }); + + req.on('error', reject); + }); +} + +async function readFormBody(req) { + const body = await readBodyText(req); + return Object.fromEntries(new URLSearchParams(body)); +} + +function getCacheBuster(searchParams) { + if(searchParams.has('DX_HTTP_CACHE')) { + const value = searchParams.get('DX_HTTP_CACHE') || ''; + return `DX_HTTP_CACHE=${encodeURIComponent(value)}`; + } + + return ''; +} + +function contentWithCacheBuster(contentPath, cacheBuster) { + if(!cacheBuster) { + return contentPath; + } + + return `${contentPath}${contentPath.includes('?') ? '&' : '?'}${cacheBuster}`; +} + +function normalizeNumber(value) { + const number = Number(value); + if(Number.isNaN(number)) { + return 0; + } + + return number; +} + +module.exports = { + contentWithCacheBuster, + escapeHtml, + escapeXmlAttr, + escapeXmlText, + formatDateForSuiteTimestamp, + getCacheBuster, + isContinuousIntegration, + jsonString, + loadPorts, + normalizeNumber, + pad2, + parseBoolean, + parseNumber, + readBodyText, + readFormBody, + resolveNodePath, + safeDecodeURIComponent, + safeReadFile, + splitCommaList, +}; diff --git a/packages/devextreme/testing/runner/lib/vectormap.js b/packages/devextreme/testing/runner/lib/vectormap.js new file mode 100644 index 000000000000..95e43a6ae56d --- /dev/null +++ b/packages/devextreme/testing/runner/lib/vectormap.js @@ -0,0 +1,222 @@ +const fs = require('fs'); +const http = require('http'); +const path = require('path'); +const { spawn, spawnSync } = require('child_process'); + +function createVectorMapService({ + packageRoot, + testingRoot, + vectorDataDirectory, + vectorMapTesterPort, + pathToNode, +}) { + const vectorMapNodeServer = { + process: null, + refs: 0, + killTimer: null, + }; + + function readThemeCssFiles() { + const bundlesPath = path.join(packageRoot, 'scss', 'bundles'); + const result = []; + + if(!fs.existsSync(bundlesPath)) { + return result; + } + + fs.readdirSync(bundlesPath, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .forEach((entry) => { + const bundleDirectory = path.join(bundlesPath, entry.name); + fs.readdirSync(bundleDirectory, { withFileTypes: true }) + .filter((file) => file.isFile() && file.name.endsWith('.scss')) + .forEach((file) => { + result.push(`${path.basename(file.name, '.scss')}.css`); + }); + }); + + return result; + } + + function readVectorMapTestData() { + if(!fs.existsSync(vectorDataDirectory)) { + return []; + } + + return fs.readdirSync(vectorDataDirectory, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.txt')) + .map((entry) => { + const filePath = path.join(vectorDataDirectory, entry.name); + return { + name: path.basename(entry.name, '.txt'), + expected: fs.readFileSync(filePath, 'utf8'), + }; + }); + } + + async function redirectRequestToVectorMapNodeServer(action, arg) { + acquireVectorMapNodeServer(); + + try { + const startTime = Date.now(); + + while(true) { + try { + const text = await httpGetText(`http://127.0.0.1:${vectorMapTesterPort}/${action}/${arg}`); + return text; + } catch(error) { + if(Date.now() - startTime > 5000) { + throw error; + } + } + } + } finally { + releaseVectorMapNodeServer(); + } + } + + function executeVectorMapConsoleApp(arg, searchParams) { + const inputDirectory = `${path.join(packageRoot, 'testing', 'content', 'VectorMapData')}${path.sep}`; + const outputDirectory = path.join(inputDirectory, '__Output'); + const settingsPath = path.join(inputDirectory, '_settings.js'); + const processFileContentPath = path.join(inputDirectory, '_processFileContent.js'); + const vectorMapUtilsNodePath = path.resolve(path.join(packageRoot, 'artifacts/js/vectormap-utils/dx.vectormaputils.node.js')); + + const args = [vectorMapUtilsNodePath, inputDirectory]; + + if(searchParams.has('file')) { + args[1] += searchParams.get('file'); + } + + args.push('--quiet', '--output', outputDirectory, '--settings', settingsPath, '--process-file-content', processFileContentPath); + + const isJson = searchParams.has('json'); + + if(isJson) { + args.push('--json'); + } + + fs.mkdirSync(outputDirectory, { recursive: true }); + + try { + const spawnResult = spawnSync(pathToNode, args, { + timeout: 15000, + stdio: 'ignore', + }); + + if(spawnResult.error && spawnResult.error.code === 'ETIMEDOUT') { + // Intentionally ignored to match legacy behavior. + } + + const extension = isJson ? '.json' : '.js'; + + return fs.readdirSync(outputDirectory, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(extension)) + .map((entry) => { + const filePath = path.join(outputDirectory, entry.name); + let text = fs.readFileSync(filePath, 'utf8'); + let variable = null; + + if(!isJson) { + const index = text.indexOf('='); + if(index > 0) { + variable = text.substring(0, index).trim(); + text = text.substring(index + 1).trim(); + + if(text.endsWith(';')) { + text = text.slice(0, -1).trim(); + } + } + } + + return { + file: `${path.basename(entry.name, extension)}${extension}`, + variable, + content: JSON.parse(text), + }; + }); + } finally { + try { + fs.rmSync(outputDirectory, { recursive: true, force: true }); + } catch(_) { + // Ignore cleanup errors. + } + } + } + + function acquireVectorMapNodeServer() { + if(vectorMapNodeServer.killTimer) { + clearTimeout(vectorMapNodeServer.killTimer); + vectorMapNodeServer.killTimer = null; + } + + if(!vectorMapNodeServer.process || vectorMapNodeServer.process.killed) { + const scriptPath = path.join(testingRoot, 'helpers', 'vectormaputils-tester.js'); + + vectorMapNodeServer.process = spawn( + pathToNode, + [scriptPath, `${vectorDataDirectory}${path.sep}`], + { + stdio: 'ignore', + }, + ); + + vectorMapNodeServer.process.on('exit', () => { + if(vectorMapNodeServer.process && vectorMapNodeServer.process.exitCode !== null) { + vectorMapNodeServer.process = null; + } + }); + } + + vectorMapNodeServer.refs += 1; + } + + function releaseVectorMapNodeServer() { + vectorMapNodeServer.refs -= 1; + + if(vectorMapNodeServer.refs <= 0) { + vectorMapNodeServer.refs = 0; + + vectorMapNodeServer.killTimer = setTimeout(() => { + if(vectorMapNodeServer.refs === 0 && vectorMapNodeServer.process) { + try { + vectorMapNodeServer.process.kill(); + } catch(_) { + // Ignore process kill failures. + } + vectorMapNodeServer.process = null; + } + vectorMapNodeServer.killTimer = null; + }, 200); + } + } + + return { + executeVectorMapConsoleApp, + readThemeCssFiles, + readVectorMapTestData, + redirectRequestToVectorMapNodeServer, + }; +} + +function httpGetText(targetUrl) { + return new Promise((resolve, reject) => { + const request = http.get(targetUrl, (response) => { + const chunks = []; + + response.on('data', (chunk) => { + chunks.push(chunk); + }); + + response.on('end', () => { + resolve(Buffer.concat(chunks).toString('utf8')); + }); + }); + + request.on('error', reject); + }); +} + +module.exports = { + createVectorMapService, +}; From dfd5dcae056666aaf8165584e048c4bceb00b8ef Mon Sep 17 00:00:00 2001 From: Eugen Zha Date: Thu, 5 Mar 2026 00:03:36 +0200 Subject: [PATCH 08/11] Fix after Copilot review --- .../devextreme/testing/runner/lib/vectormap.js | 16 ++++++++++++++-- .../runner/templates/run-suite.template.html | 2 +- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/devextreme/testing/runner/lib/vectormap.js b/packages/devextreme/testing/runner/lib/vectormap.js index 95e43a6ae56d..f9d0c5eae7e2 100644 --- a/packages/devextreme/testing/runner/lib/vectormap.js +++ b/packages/devextreme/testing/runner/lib/vectormap.js @@ -68,6 +68,8 @@ function createVectorMapService({ if(Date.now() - startTime > 5000) { throw error; } + + await wait(50); } } } finally { @@ -104,8 +106,12 @@ function createVectorMapService({ stdio: 'ignore', }); - if(spawnResult.error && spawnResult.error.code === 'ETIMEDOUT') { - // Intentionally ignored to match legacy behavior. + if(spawnResult.error) { + if(spawnResult.error.code === 'ETIMEDOUT') { + // Intentionally ignored to match legacy behavior. + } else { + throw spawnResult.error; + } } const extension = isJson ? '.json' : '.js'; @@ -217,6 +223,12 @@ function httpGetText(targetUrl) { }); } +function wait(timeout) { + return new Promise((resolve) => { + setTimeout(resolve, timeout); + }); +} + module.exports = { createVectorMapService, }; diff --git a/packages/devextreme/testing/runner/templates/run-suite.template.html b/packages/devextreme/testing/runner/templates/run-suite.template.html index f991d208e9b7..87d32cb2f435 100644 --- a/packages/devextreme/testing/runner/templates/run-suite.template.html +++ b/packages/devextreme/testing/runner/templates/run-suite.template.html @@ -81,7 +81,7 @@ QUnit.config.urlConfig.push({ id: "nocsp", label: "No CSP", - tooltip: "Use noscp components without CSP checks", + tooltip: "Use nocsp components without CSP checks", }); function notifyExtraDoneCall() { From 94a2cd5db66a79c63d0d31bb87548fbc1eb5d03d Mon Sep 17 00:00:00 2001 From: Eugen Zha Date: Thu, 5 Mar 2026 13:13:20 +0200 Subject: [PATCH 09/11] Convert to TS --- packages/devextreme/docker-ci.sh | 5 +- packages/devextreme/testing/launch | 14 +- packages/devextreme/testing/runner/.gitignore | 1 + packages/devextreme/testing/runner/index.js | 446 ---------------- packages/devextreme/testing/runner/index.ts | 481 ++++++++++++++++++ .../devextreme/testing/runner/lib/http.js | 66 --- .../devextreme/testing/runner/lib/http.ts | 57 +++ .../devextreme/testing/runner/lib/logger.js | 83 --- .../devextreme/testing/runner/lib/logger.ts | 88 ++++ .../devextreme/testing/runner/lib/pages.js | 247 --------- .../devextreme/testing/runner/lib/pages.ts | 269 ++++++++++ .../devextreme/testing/runner/lib/results.js | 161 ------ .../devextreme/testing/runner/lib/results.ts | 196 +++++++ .../devextreme/testing/runner/lib/static.js | 165 ------ .../devextreme/testing/runner/lib/static.ts | 188 +++++++ .../devextreme/testing/runner/lib/suites.js | 156 ------ .../devextreme/testing/runner/lib/suites.ts | 196 +++++++ .../testing/runner/lib/templates.js | 55 -- .../testing/runner/lib/templates.ts | 76 +++ .../devextreme/testing/runner/lib/types.ts | 88 ++++ .../devextreme/testing/runner/lib/utils.js | 164 ------ .../devextreme/testing/runner/lib/utils.ts | 151 ++++++ .../testing/runner/lib/vectormap.js | 234 --------- .../testing/runner/lib/vectormap.ts | 263 ++++++++++ .../devextreme/testing/runner/tsconfig.json | 24 + packages/devextreme/tsconfig.json | 3 +- 26 files changed, 2096 insertions(+), 1781 deletions(-) delete mode 100644 packages/devextreme/testing/runner/index.js create mode 100644 packages/devextreme/testing/runner/index.ts delete mode 100644 packages/devextreme/testing/runner/lib/http.js create mode 100644 packages/devextreme/testing/runner/lib/http.ts delete mode 100644 packages/devextreme/testing/runner/lib/logger.js create mode 100644 packages/devextreme/testing/runner/lib/logger.ts delete mode 100644 packages/devextreme/testing/runner/lib/pages.js create mode 100644 packages/devextreme/testing/runner/lib/pages.ts delete mode 100644 packages/devextreme/testing/runner/lib/results.js create mode 100644 packages/devextreme/testing/runner/lib/results.ts delete mode 100644 packages/devextreme/testing/runner/lib/static.js create mode 100644 packages/devextreme/testing/runner/lib/static.ts delete mode 100644 packages/devextreme/testing/runner/lib/suites.js create mode 100644 packages/devextreme/testing/runner/lib/suites.ts delete mode 100644 packages/devextreme/testing/runner/lib/templates.js create mode 100644 packages/devextreme/testing/runner/lib/templates.ts create mode 100644 packages/devextreme/testing/runner/lib/types.ts delete mode 100644 packages/devextreme/testing/runner/lib/utils.js create mode 100644 packages/devextreme/testing/runner/lib/utils.ts delete mode 100644 packages/devextreme/testing/runner/lib/vectormap.js create mode 100644 packages/devextreme/testing/runner/lib/vectormap.ts create mode 100644 packages/devextreme/testing/runner/tsconfig.json diff --git a/packages/devextreme/docker-ci.sh b/packages/devextreme/docker-ci.sh index 18ab388f1777..21c9bd66439e 100755 --- a/packages/devextreme/docker-ci.sh +++ b/packages/devextreme/docker-ci.sh @@ -79,8 +79,11 @@ function run_test_impl { pnpm run build fi + echo "Compiling TypeScript test runner..." + pnpm exec tsc -p ./testing/runner/tsconfig.json + echo "Starting Node.js test runner..." - node ./testing/runner/index.js --single-run & runner_pid=$! + node ./testing/runner/dist/index.js --single-run & runner_pid=$! echo "Runner PID: $runner_pid" local max_attempts=30 diff --git a/packages/devextreme/testing/launch b/packages/devextreme/testing/launch index f82e48f6ced0..590a90654e5a 100755 --- a/packages/devextreme/testing/launch +++ b/packages/devextreme/testing/launch @@ -2,7 +2,7 @@ const http = require('http'); const { join } = require('path'); -const { spawn } = require('child_process'); +const { spawn, spawnSync } = require('child_process'); const { platform } = require('os'); const { env, versions } = require('process'); const PORT = require('./../ports.json').qunit; @@ -14,9 +14,19 @@ if(parseInt(versions.node.split('.')[0]) < 6) { execRunner(); function execRunner () { + const tscResult = spawnSync( + 'pnpm', + [ 'exec', 'tsc', '-p', join(__dirname, 'runner/tsconfig.json') ], + { stdio: 'inherit', shell: true } + ); + + if(tscResult.status !== 0) { + throw 'Failed to compile testing runner'; + } + spawn( 'node', - [ join(__dirname, 'runner/index.js') ], + [ join(__dirname, 'runner/dist/index.js') ], { stdio: 'inherit', shell: true } ); diff --git a/packages/devextreme/testing/runner/.gitignore b/packages/devextreme/testing/runner/.gitignore index c46409733829..966c7edbb8f9 100644 --- a/packages/devextreme/testing/runner/.gitignore +++ b/packages/devextreme/testing/runner/.gitignore @@ -1,3 +1,4 @@ .vs launchSettings.json *.user +dist diff --git a/packages/devextreme/testing/runner/index.js b/packages/devextreme/testing/runner/index.js deleted file mode 100644 index e3043cc8a36b..000000000000 --- a/packages/devextreme/testing/runner/index.js +++ /dev/null @@ -1,446 +0,0 @@ -#!/usr/bin/env node - -const fs = require('fs'); -const os = require('os'); -const path = require('path'); -const http = require('http'); - -const { - contentWithCacheBuster, - escapeHtml, - escapeXmlAttr, - escapeXmlText, - formatDateForSuiteTimestamp, - getCacheBuster, - isContinuousIntegration, - jsonString, - loadPorts, - normalizeNumber, - parseBoolean, - parseNumber, - readBodyText, - readFormBody, - resolveNodePath, - safeDecodeURIComponent, - safeReadFile, - splitCommaList, -} = require('./lib/utils'); -const { createRunnerLogger } = require('./lib/logger'); -const { createTemplateRenderer } = require('./lib/templates'); -const { createPagesRenderer } = require('./lib/pages'); -const { createSuitesService } = require('./lib/suites'); -const { createResultsReporter } = require('./lib/results'); -const { createVectorMapService } = require('./lib/vectormap'); -const { - sendHtml, - sendJson, - sendJsonText, - sendNotFound, - sendText, - sendXml, - setNoCacheHeaders, - setStaticCacheHeaders, -} = require('./lib/http'); -const { createStaticFileService } = require('./lib/static'); - -const KNOWN_CONSTELLATIONS = new Set(['export', 'misc', 'ui', 'ui.widgets', 'ui.editors', 'ui.grid', 'ui.scheduler']); - -const PACKAGE_ROOT = path.resolve(__dirname, '../..'); -const WORKSPACE_ROOT = path.resolve(PACKAGE_ROOT, '../..'); -const TESTING_ROOT = path.join(PACKAGE_ROOT, 'testing'); -const TESTS_ROOT = path.join(TESTING_ROOT, 'tests'); -const VECTOR_DATA_DIRECTORY = path.join(TESTING_ROOT, 'content', 'VectorMapData'); -const TEMPLATES_ROOT = path.join(__dirname, 'templates'); - -const COMPLETED_SUITES_FILENAME = path.join(TESTING_ROOT, 'CompletedSuites.txt'); -const LAST_SUITE_TIME_FILENAME = path.join(TESTING_ROOT, 'LastSuiteTime.txt'); -const RESULTS_XML_FILENAME = path.join(TESTING_ROOT, 'Results.xml'); -const MISC_ERRORS_FILENAME = path.join(TESTING_ROOT, 'MiscErrors.log'); -const RAW_LOG_FILENAME = path.join(TESTING_ROOT, 'RawLog.txt'); - -const RUN_FLAGS = { - singleRun: process.argv.includes('--single-run'), - isContinuousIntegration: isContinuousIntegration(), -}; - -const PORTS = loadPorts(path.join(PACKAGE_ROOT, 'ports.json')); -const QUNIT_PORT = Number(PORTS.qunit); -const VECTOR_MAP_TESTER_PORT = Number(PORTS['vectormap-utils-tester']); - -const PATH_TO_NODE = resolveNodePath(); - -const logger = createRunnerLogger(RAW_LOG_FILENAME); -const templates = createTemplateRenderer(TEMPLATES_ROOT, escapeHtml); -const pages = createPagesRenderer({ - contentWithCacheBuster, - getCacheBuster, - jsonString, - renderTemplate: templates.renderTemplate, -}); -const suites = createSuitesService({ - knownConstellations: KNOWN_CONSTELLATIONS, - testsRoot: TESTS_ROOT, -}); -const results = createResultsReporter({ - escapeXmlAttr, - escapeXmlText, - normalizeNumber, -}); -const vectorMap = createVectorMapService({ - packageRoot: PACKAGE_ROOT, - testingRoot: TESTING_ROOT, - vectorDataDirectory: VECTOR_DATA_DIRECTORY, - vectorMapTesterPort: VECTOR_MAP_TESTER_PORT, - pathToNode: PATH_TO_NODE, -}); -const staticFiles = createStaticFileService({ - escapeHtml, - rootDirectory: WORKSPACE_ROOT, - setNoCacheHeaders, - setStaticCacheHeaders, -}); - -start(); - -function start() { - const server = http.createServer((req, res) => { - handleRequest(req, res).catch((error) => { - logger.writeError(error && error.stack ? error.stack : String(error)); - if(!res.headersSent) { - setNoCacheHeaders(res); - res.statusCode = 500; - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - } - if(!res.writableEnded) { - res.end('Internal Server Error'); - } - }); - }); - - server.listen(QUNIT_PORT, '0.0.0.0', () => { - logger.writeLine(`QUnit runner server listens on http://0.0.0.0:${QUNIT_PORT}...`); - }); -} - -async function handleRequest(req, res) { - const requestUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`); - const pathname = safeDecodeURIComponent(requestUrl.pathname); - const pathnameLower = pathname.toLowerCase(); - - if(req.method === 'GET' && (pathname === '/' || pathnameLower === '/main/index')) { - return sendHtml(res, pages.renderIndexPage()); - } - - if(req.method === 'GET') { - const suitesJsonMatch = pathname.match(/^\/Main\/SuitesJson(?:\/(.+))?$/i); - if(suitesJsonMatch) { - const id = suitesJsonMatch[1] - ? safeDecodeURIComponent(suitesJsonMatch[1]) - : requestUrl.searchParams.get('id'); - const suitesList = suites.readSuites(id || ''); - return sendJson(res, suitesList); - } - } - - if(req.method === 'GET' && pathnameLower === '/main/categoriesjson') { - return sendJson(res, suites.readCategories()); - } - - if((req.method === 'GET' || req.method === 'HEAD') - && (pathnameLower === '/run' || pathnameLower === '/run/' || pathnameLower === '/main/runall')) { - if(req.method === 'HEAD') { - setNoCacheHeaders(res); - res.statusCode = 200; - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(); - return; - } - - const model = buildRunAllModel(requestUrl.searchParams); - const runProps = assignBaseRunProps(requestUrl.searchParams); - return sendHtml(res, pages.renderRunAllPage(model, runProps)); - } - - if(req.method === 'GET') { - const runSuiteMatch = pathname.match(/^\/run\/([^/]+)\/(.+\.js)$/i); - if(runSuiteMatch) { - const catName = safeDecodeURIComponent(runSuiteMatch[1]); - const suiteName = safeDecodeURIComponent(runSuiteMatch[2]); - const model = suites.buildRunSuiteModel(catName, suiteName); - const runProps = assignBaseRunProps(requestUrl.searchParams); - return sendHtml(res, pages.renderRunSuitePage(model, runProps, requestUrl.searchParams)); - } - } - - if(req.method === 'GET' && pathnameLower === '/main/runsuite') { - const catName = requestUrl.searchParams.get('catName') || ''; - const suiteName = requestUrl.searchParams.get('suiteName') || ''; - - if(!catName || !suiteName) { - return sendNotFound(res); - } - - const model = suites.buildRunSuiteModel(catName, suiteName); - const runProps = assignBaseRunProps(requestUrl.searchParams); - return sendHtml(res, pages.renderRunSuitePage(model, runProps, requestUrl.searchParams)); - } - - if(req.method === 'POST' && pathnameLower === '/main/notifyteststarted') { - const form = await readFormBody(req); - const name = String(form.name || ''); - - try { - logger.writeLine(` [ run] ${name}`); - } catch(_) { - // Ignore logging errors. - } - - return sendText(res, 'OK'); - } - - if(req.method === 'POST' && pathnameLower === '/main/notifytestcompleted') { - const form = await readFormBody(req); - const name = String(form.name || ''); - const passed = parseBoolean(form.passed); - - try { - logger.writeLine(` [${passed ? ' ok' : 'fail'}] ${name}`); - } catch(_) { - // Ignore logging errors. - } - - return sendText(res, 'OK'); - } - - if(req.method === 'POST' && pathnameLower === '/main/notifysuitefinalized') { - const form = await readFormBody(req); - const name = String(form.name || ''); - const passed = parseBoolean(form.passed); - const runtime = parseNumber(form.runtime); - - try { - if(passed && RUN_FLAGS.isContinuousIntegration) { - fs.appendFileSync(COMPLETED_SUITES_FILENAME, `${name}${os.EOL}`); - } - - if(RUN_FLAGS.isContinuousIntegration) { - writeLastSuiteTime(); - } - - logger.write(passed ? '[ OK ' : '[FAIL', passed ? 'green' : 'red'); - const seconds = Number((runtime / 1000).toFixed(3)); - logger.writeLine(`] ${name} in ${seconds}s`); - } catch(_) { - // Preserve legacy behavior: swallow errors. - } - - return sendText(res, 'OK'); - } - - if(req.method === 'POST' && pathnameLower === '/main/notifyisalive') { - try { - if(RUN_FLAGS.isContinuousIntegration) { - writeLastSuiteTime(); - } - } catch(_) { - // Preserve legacy behavior: swallow errors. - } - - return sendText(res, 'OK'); - } - - if(req.method === 'POST' && pathnameLower === '/main/saveresults') { - return saveResults(req, res); - } - - if(req.method === 'GET' && pathnameLower === '/main/displayresults') { - const stylesheetUrl = '/packages/devextreme/testing/content/unittests.xsl'; - const xml = [ - '', - ``, - '', - safeReadFile(RESULTS_XML_FILENAME), - '', - '', - ].join('\n'); - - return sendXml(res, xml); - } - - if(req.method === 'POST' && pathnameLower === '/main/logmiscerror') { - const form = await readFormBody(req); - const message = String(form.msg || ''); - logMiscErrorCore(message); - return sendText(res, 'OK'); - } - - if(req.method === 'GET' && pathnameLower === '/themes-test/get-css-files-list') { - const list = vectorMap.readThemeCssFiles(); - return sendJson(res, list); - } - - if(req.method === 'GET' && pathnameLower === '/testvectormapdata/gettestdata') { - const data = vectorMap.readVectorMapTestData(); - return sendJson(res, data); - } - - if(req.method === 'GET') { - const parseBufferMatch = pathname.match(/^\/TestVectorMapData\/ParseBuffer\/(.+)$/i); - if(parseBufferMatch) { - const id = safeDecodeURIComponent(parseBufferMatch[1]); - const responseText = await vectorMap.redirectRequestToVectorMapNodeServer('parse-buffer', id); - return sendJsonText(res, responseText); - } - } - - if(req.method === 'GET') { - const readAndParseMatch = pathname.match(/^\/TestVectorMapData\/ReadAndParse\/(.+)$/i); - if(readAndParseMatch) { - const id = safeDecodeURIComponent(readAndParseMatch[1]); - const responseText = await vectorMap.redirectRequestToVectorMapNodeServer('read-and-parse', id); - return sendJsonText(res, responseText); - } - } - - if(req.method === 'GET') { - const executeConsoleAppMatch = pathname.match(/^\/TestVectorMapData\/ExecuteConsoleApp(?:\/(.*))?$/i); - if(executeConsoleAppMatch) { - const arg = safeDecodeURIComponent(executeConsoleAppMatch[1] || ''); - const result = vectorMap.executeVectorMapConsoleApp(arg, requestUrl.searchParams); - return sendJson(res, result); - } - } - - if(staticFiles.tryServeStatic(req, res, pathname, requestUrl.searchParams)) { - return; - } - - return sendNotFound(res); -} - -function buildRunAllModel(searchParams) { - let includeSet = null; - let excludeSet = null; - let excludeSuites = null; - let partIndex = 0; - let partCount = 1; - - let constellation = searchParams.get('constellation'); - const include = searchParams.get('include'); - const exclude = searchParams.get('exclude'); - - if(include) { - includeSet = new Set(splitCommaList(include)); - } - - if(exclude) { - excludeSet = new Set(splitCommaList(exclude)); - } - - if(constellation && constellation.includes('(') && constellation.endsWith(')')) { - const [name, partInfo] = constellation.slice(0, -1).split('('); - const parts = partInfo.split('/'); - - constellation = name; - partIndex = Number(parts[0]) - 1; - partCount = Number(parts[1]); - } - - if(RUN_FLAGS.isContinuousIntegration && fs.existsSync(COMPLETED_SUITES_FILENAME)) { - const completedSuites = fs.readFileSync(COMPLETED_SUITES_FILENAME, 'utf8') - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); - - excludeSuites = new Set(completedSuites); - } - - const packageJson = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')); - - return { - Constellation: constellation || '', - CategoriesList: include || '', - Version: String(packageJson.version || ''), - Suites: suites.getAllSuites({ - deviceMode: hasDeviceModeFlag(searchParams), - constellation: constellation || '', - includeCategories: includeSet, - excludeCategories: excludeSet, - excludeSuites, - partIndex, - partCount, - }), - }; -} - -function assignBaseRunProps(searchParams) { - const result = { - IsContinuousIntegration: RUN_FLAGS.isContinuousIntegration, - NoGlobals: searchParams.has('noglobals'), - NoTimers: searchParams.has('notimers'), - NoTryCatch: searchParams.has('notrycatch'), - NoJQuery: searchParams.has('nojquery'), - ShadowDom: searchParams.has('shadowDom'), - WorkerInWindow: searchParams.has('workerinwindow'), - NoCsp: searchParams.has('nocsp'), - MaxWorkers: null, - }; - - if(process.env.MAX_WORKERS && /^\d+$/.test(process.env.MAX_WORKERS)) { - result.MaxWorkers = Number(process.env.MAX_WORKERS); - } - - return result; -} - -function hasDeviceModeFlag(searchParams) { - return searchParams.has('deviceMode'); -} - -async function saveResults(req, res) { - let hasFailure = false; - let xml = ''; - - try { - const json = await readBodyText(req); - results.validateResultsJson(json); - - const parsedResults = JSON.parse(json); - hasFailure = Number(parsedResults.failures) > 0; - xml = results.testResultsToXml(parsedResults); - - if(RUN_FLAGS.singleRun) { - logger.writeLine(); - results.printTextReport(parsedResults, logger.writeLine.bind(logger)); - } - } catch(error) { - logMiscErrorCore(`Failed to save results. ${error && error.stack ? error.stack : String(error)}`); - hasFailure = true; - } - - fs.writeFileSync(RESULTS_XML_FILENAME, xml, 'utf8'); - - sendText(res, 'OK'); - - if(RUN_FLAGS.singleRun) { - setTimeout(() => { - process.exit(hasFailure ? 1 : 0); - }, 0); - } -} - -function writeLastSuiteTime() { - fs.writeFileSync(LAST_SUITE_TIME_FILENAME, formatDateForSuiteTimestamp(new Date()), 'utf8'); -} - -function logMiscErrorCore(data) { - if(!RUN_FLAGS.isContinuousIntegration) { - return; - } - - try { - fs.appendFileSync(MISC_ERRORS_FILENAME, `${data}${os.EOL}`, 'utf8'); - } catch(_) { - // Ignore logging errors. - } -} diff --git a/packages/devextreme/testing/runner/index.ts b/packages/devextreme/testing/runner/index.ts new file mode 100644 index 000000000000..7841f06271b2 --- /dev/null +++ b/packages/devextreme/testing/runner/index.ts @@ -0,0 +1,481 @@ +#!/usr/bin/env node +/* eslint-disable @typescript-eslint/no-use-before-define */ + +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + contentWithCacheBuster, + escapeHtml, + escapeXmlAttr, + escapeXmlText, + formatDateForSuiteTimestamp, + getCacheBuster, + isContinuousIntegration, + jsonString, + loadPorts, + normalizeNumber, + parseBoolean, + parseNumber, + readBodyText, + readFormBody, + resolveNodePath, + safeDecodeURIComponent, + safeReadFile, + splitCommaList, +} from './lib/utils'; +import { createRunnerLogger } from './lib/logger'; +import { createTemplateRenderer } from './lib/templates'; +import { createPagesRenderer } from './lib/pages'; +import { createSuitesService } from './lib/suites'; +import { createResultsReporter } from './lib/results'; +import { createVectorMapService } from './lib/vectormap'; +import { + sendHtml, + sendJson, + sendJsonText, + sendNotFound, + sendText, + sendXml, + setNoCacheHeaders, + setStaticCacheHeaders, +} from './lib/http'; +import { createStaticFileService } from './lib/static'; +import { BaseRunProps, RunAllModel, TestResultsPayload } from './lib/types'; + +const KNOWN_CONSTELLATIONS = new Set(['export', 'misc', 'ui', 'ui.widgets', 'ui.editors', 'ui.grid', 'ui.scheduler']); + +const RUNNER_ROOT = fs.existsSync(path.join(__dirname, 'templates')) + ? __dirname + : path.resolve(__dirname, '..'); +const PACKAGE_ROOT = path.resolve(RUNNER_ROOT, '../..'); +const WORKSPACE_ROOT = path.resolve(PACKAGE_ROOT, '../..'); +const TESTING_ROOT = path.join(PACKAGE_ROOT, 'testing'); +const TESTS_ROOT = path.join(TESTING_ROOT, 'tests'); +const VECTOR_DATA_DIRECTORY = path.join(TESTING_ROOT, 'content', 'VectorMapData'); +const TEMPLATES_ROOT = path.join(RUNNER_ROOT, 'templates'); + +const COMPLETED_SUITES_FILENAME = path.join(TESTING_ROOT, 'CompletedSuites.txt'); +const LAST_SUITE_TIME_FILENAME = path.join(TESTING_ROOT, 'LastSuiteTime.txt'); +const RESULTS_XML_FILENAME = path.join(TESTING_ROOT, 'Results.xml'); +const MISC_ERRORS_FILENAME = path.join(TESTING_ROOT, 'MiscErrors.log'); +const RAW_LOG_FILENAME = path.join(TESTING_ROOT, 'RawLog.txt'); + +const RUN_FLAGS = { + singleRun: process.argv.includes('--single-run'), + isContinuousIntegration: isContinuousIntegration(), +}; + +const PORTS = loadPorts(path.join(PACKAGE_ROOT, 'ports.json')); +const QUNIT_PORT = Number(PORTS.qunit); +const VECTOR_MAP_TESTER_PORT = Number(PORTS['vectormap-utils-tester']); + +const PATH_TO_NODE = resolveNodePath(); + +const logger = createRunnerLogger(RAW_LOG_FILENAME); +const templates = createTemplateRenderer(TEMPLATES_ROOT, escapeHtml); +const pages = createPagesRenderer({ + contentWithCacheBuster, + getCacheBuster, + jsonString, + renderTemplate: templates.renderTemplate, +}); +const suitesService = createSuitesService({ + knownConstellations: KNOWN_CONSTELLATIONS, + testsRoot: TESTS_ROOT, +}); +const resultsReporter = createResultsReporter({ + escapeXmlAttr, + escapeXmlText, + normalizeNumber, +}); +const vectorMapService = createVectorMapService({ + packageRoot: PACKAGE_ROOT, + testingRoot: TESTING_ROOT, + vectorDataDirectory: VECTOR_DATA_DIRECTORY, + vectorMapTesterPort: VECTOR_MAP_TESTER_PORT, + pathToNode: PATH_TO_NODE, +}); +const staticFiles = createStaticFileService({ + escapeHtml, + rootDirectory: WORKSPACE_ROOT, + setNoCacheHeaders, + setStaticCacheHeaders, +}); + +start(); + +function start(): void { + const server = http.createServer((req, res) => { + handleRequest(req, res).catch((error: unknown) => { + logger.writeError(error instanceof Error && error.stack ? error.stack : String(error)); + if (!res.headersSent) { + setNoCacheHeaders(res); + res.statusCode = 500; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + } + if (!res.writableEnded) { + res.end('Internal Server Error'); + } + }); + }); + + server.listen(QUNIT_PORT, '0.0.0.0', () => { + logger.writeLine(`QUnit runner server listens on http://0.0.0.0:${QUNIT_PORT}...`); + }); +} + +async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { + const requestUrl = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + const pathname = safeDecodeURIComponent(requestUrl.pathname); + const pathnameLower = pathname.toLowerCase(); + + if (req.method === 'GET' && (pathname === '/' || pathnameLower === '/main/index')) { + sendHtml(res, pages.renderIndexPage()); + return; + } + + if (req.method === 'GET') { + const suitesJsonMatch = /^\/Main\/SuitesJson(?:\/(.+))?$/i.exec(pathname); + if (suitesJsonMatch) { + const id = suitesJsonMatch[1] + ? safeDecodeURIComponent(suitesJsonMatch[1]) + : requestUrl.searchParams.get('id'); + const suites = suitesService.readSuites(id ?? ''); + sendJson(res, suites); + return; + } + } + + if (req.method === 'GET' && pathnameLower === '/main/categoriesjson') { + sendJson(res, suitesService.readCategories()); + return; + } + + if ((req.method === 'GET' || req.method === 'HEAD') + && (pathnameLower === '/run' || pathnameLower === '/run/' || pathnameLower === '/main/runall')) { + if (req.method === 'HEAD') { + setNoCacheHeaders(res); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(); + return; + } + + const model = buildRunAllModel(requestUrl.searchParams); + const runProps = assignBaseRunProps(requestUrl.searchParams); + sendHtml(res, pages.renderRunAllPage(model, runProps)); + return; + } + + if (req.method === 'GET') { + const runSuiteMatch = /^\/run\/([^/]+)\/(.+\.js)$/i.exec(pathname); + if (runSuiteMatch) { + const catName = safeDecodeURIComponent(runSuiteMatch[1]); + const suiteName = safeDecodeURIComponent(runSuiteMatch[2]); + const model = suitesService.buildRunSuiteModel(catName, suiteName); + const runProps = assignBaseRunProps(requestUrl.searchParams); + sendHtml(res, pages.renderRunSuitePage(model, runProps, requestUrl.searchParams)); + return; + } + } + + if (req.method === 'GET' && pathnameLower === '/main/runsuite') { + const catName = requestUrl.searchParams.get('catName') ?? ''; + const suiteName = requestUrl.searchParams.get('suiteName') ?? ''; + + if (!catName || !suiteName) { + sendNotFound(res); + return; + } + + const model = suitesService.buildRunSuiteModel(catName, suiteName); + const runProps = assignBaseRunProps(requestUrl.searchParams); + sendHtml(res, pages.renderRunSuitePage(model, runProps, requestUrl.searchParams)); + return; + } + + if (req.method === 'POST' && pathnameLower === '/main/notifyteststarted') { + const form = await readFormBody(req); + const name = String(form.name ?? ''); + + try { + logger.writeLine(` [ run] ${name}`); + } catch { + // Ignore logging errors. + } + + sendText(res, 'OK'); + return; + } + + if (req.method === 'POST' && pathnameLower === '/main/notifytestcompleted') { + const form = await readFormBody(req); + const name = String(form.name ?? ''); + const passed = parseBoolean(form.passed); + + try { + logger.writeLine(` [${passed ? ' ok' : 'fail'}] ${name}`); + } catch { + // Ignore logging errors. + } + + sendText(res, 'OK'); + return; + } + + if (req.method === 'POST' && pathnameLower === '/main/notifysuitefinalized') { + const form = await readFormBody(req); + const name = String(form.name ?? ''); + const passed = parseBoolean(form.passed); + const runtime = parseNumber(form.runtime); + + try { + if (passed && RUN_FLAGS.isContinuousIntegration) { + fs.appendFileSync(COMPLETED_SUITES_FILENAME, `${name}${os.EOL}`); + } + + if (RUN_FLAGS.isContinuousIntegration) { + writeLastSuiteTime(); + } + + logger.write(passed ? '[ OK ' : '[FAIL', passed ? 'green' : 'red'); + const seconds = Number((runtime / 1000).toFixed(3)); + logger.writeLine(`] ${name} in ${seconds}s`); + } catch { + // Preserve legacy behavior: swallow errors. + } + + sendText(res, 'OK'); + return; + } + + if (req.method === 'POST' && pathnameLower === '/main/notifyisalive') { + try { + if (RUN_FLAGS.isContinuousIntegration) { + writeLastSuiteTime(); + } + } catch { + // Preserve legacy behavior: swallow errors. + } + + sendText(res, 'OK'); + return; + } + + if (req.method === 'POST' && pathnameLower === '/main/saveresults') { + await saveResults(req, res); + return; + } + + if (req.method === 'GET' && pathnameLower === '/main/displayresults') { + const stylesheetUrl = '/packages/devextreme/testing/content/unittests.xsl'; + const xml = [ + '', + ``, + '', + safeReadFile(RESULTS_XML_FILENAME), + '', + '', + ].join('\n'); + + sendXml(res, xml); + return; + } + + if (req.method === 'POST' && pathnameLower === '/main/logmiscerror') { + const form = await readFormBody(req); + const message = String(form.msg ?? ''); + logMiscErrorCore(message); + sendText(res, 'OK'); + return; + } + + if (req.method === 'GET' && pathnameLower === '/themes-test/get-css-files-list') { + const list = vectorMapService.readThemeCssFiles(); + sendJson(res, list); + return; + } + + if (req.method === 'GET' && pathnameLower === '/testvectormapdata/gettestdata') { + const data = vectorMapService.readVectorMapTestData(); + sendJson(res, data); + return; + } + + if (req.method === 'GET') { + const parseBufferMatch = /^\/TestVectorMapData\/ParseBuffer\/(.+)$/i.exec(pathname); + if (parseBufferMatch) { + const id = safeDecodeURIComponent(parseBufferMatch[1]); + const responseText = await vectorMapService.redirectRequestToVectorMapNodeServer('parse-buffer', id); + sendJsonText(res, responseText); + return; + } + } + + if (req.method === 'GET') { + const readAndParseMatch = /^\/TestVectorMapData\/ReadAndParse\/(.+)$/i.exec(pathname); + if (readAndParseMatch) { + const id = safeDecodeURIComponent(readAndParseMatch[1]); + const responseText = await vectorMapService.redirectRequestToVectorMapNodeServer('read-and-parse', id); + sendJsonText(res, responseText); + return; + } + } + + if (req.method === 'GET') { + const executeConsoleAppMatch = /^\/TestVectorMapData\/ExecuteConsoleApp(?:\/(.*))?$/i.exec(pathname); + if (executeConsoleAppMatch) { + const arg = safeDecodeURIComponent(executeConsoleAppMatch[1] || ''); + const result = vectorMapService.executeVectorMapConsoleApp(arg, requestUrl.searchParams); + sendJson(res, result); + return; + } + } + + if (staticFiles.tryServeStatic(req, res, pathname, requestUrl.searchParams)) { + return; + } + + sendNotFound(res); +} + +function buildRunAllModel(searchParams: URLSearchParams): RunAllModel { + let includeSet: Set | null = null; + let excludeSet: Set | null = null; + let excludeSuites: Set | null = null; + let partIndex = 0; + let partCount = 1; + + let constellation = searchParams.get('constellation'); + const include = searchParams.get('include'); + const exclude = searchParams.get('exclude'); + + if (include) { + includeSet = new Set(splitCommaList(include)); + } + + if (exclude) { + excludeSet = new Set(splitCommaList(exclude)); + } + + if (constellation && constellation.includes('(') && constellation.endsWith(')')) { + const [name, partInfo] = constellation.slice(0, -1).split('('); + const parts = partInfo.split('/'); + + constellation = name; + partIndex = Number(parts[0]) - 1; + partCount = Number(parts[1]); + } + + if (RUN_FLAGS.isContinuousIntegration && fs.existsSync(COMPLETED_SUITES_FILENAME)) { + const completedSuites = fs.readFileSync(COMPLETED_SUITES_FILENAME, 'utf8') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + excludeSuites = new Set(completedSuites); + } + + const packageJson = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')) as { + version?: unknown; + }; + + return { + Constellation: constellation ?? '', + CategoriesList: include ?? '', + Version: stringifyPrimitive(packageJson.version), + Suites: suitesService.getAllSuites({ + deviceMode: hasDeviceModeFlag(searchParams), + constellation: constellation ?? '', + includeCategories: includeSet, + excludeCategories: excludeSet, + excludeSuites, + partIndex, + partCount, + }), + }; +} + +function assignBaseRunProps(searchParams: URLSearchParams): BaseRunProps { + const result: BaseRunProps = { + IsContinuousIntegration: RUN_FLAGS.isContinuousIntegration, + NoGlobals: searchParams.has('noglobals'), + NoTimers: searchParams.has('notimers'), + NoTryCatch: searchParams.has('notrycatch'), + NoJQuery: searchParams.has('nojquery'), + ShadowDom: searchParams.has('shadowDom'), + WorkerInWindow: searchParams.has('workerinwindow'), + NoCsp: searchParams.has('nocsp'), + MaxWorkers: null, + }; + + if (process.env.MAX_WORKERS && /^\d+$/.test(process.env.MAX_WORKERS)) { + result.MaxWorkers = Number(process.env.MAX_WORKERS); + } + + return result; +} + +function hasDeviceModeFlag(searchParams: URLSearchParams): boolean { + return searchParams.has('deviceMode'); +} + +async function saveResults(req: http.IncomingMessage, res: http.ServerResponse): Promise { + let hasFailure = false; + let xml = ''; + + try { + const json = await readBodyText(req); + resultsReporter.validateResultsJson(json); + + const parsedResults = JSON.parse(json) as TestResultsPayload; + hasFailure = Number(parsedResults.failures) > 0; + xml = resultsReporter.testResultsToXml(parsedResults); + + if (RUN_FLAGS.singleRun) { + logger.writeLine(); + resultsReporter.printTextReport(parsedResults, logger.writeLine.bind(logger)); + } + } catch(error) { + const message = error instanceof Error && error.stack ? error.stack : String(error); + logMiscErrorCore(`Failed to save results. ${message}`); + hasFailure = true; + } + + fs.writeFileSync(RESULTS_XML_FILENAME, xml, 'utf8'); + + sendText(res, 'OK'); + + if (RUN_FLAGS.singleRun) { + setTimeout(() => { + process.exit(hasFailure ? 1 : 0); + }, 0); + } +} + +function writeLastSuiteTime(): void { + fs.writeFileSync(LAST_SUITE_TIME_FILENAME, formatDateForSuiteTimestamp(new Date()), 'utf8'); +} + +function logMiscErrorCore(data: string): void { + if (!RUN_FLAGS.isContinuousIntegration) { + return; + } + + try { + fs.appendFileSync(MISC_ERRORS_FILENAME, `${data}${os.EOL}`, 'utf8'); + } catch { + // Ignore logging errors. + } +} + +function stringifyPrimitive(value: unknown): string { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + + return ''; +} diff --git a/packages/devextreme/testing/runner/lib/http.js b/packages/devextreme/testing/runner/lib/http.js deleted file mode 100644 index eb04bedbba85..000000000000 --- a/packages/devextreme/testing/runner/lib/http.js +++ /dev/null @@ -1,66 +0,0 @@ -function setNoCacheHeaders(res) { - res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); - res.setHeader('Pragma', 'no-cache'); - res.setHeader('Expires', '0'); -} - -function setStaticCacheHeaders(res, searchParams) { - if(searchParams.has('DX_HTTP_CACHE')) { - res.setHeader('Cache-Control', 'public, max-age=31536000'); - } else { - res.setHeader('Cache-Control', 'private, must-revalidate, max-age=0'); - } -} - -function sendHtml(res, html) { - setNoCacheHeaders(res); - res.statusCode = 200; - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(html); -} - -function sendJson(res, payload) { - setNoCacheHeaders(res); - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.end(JSON.stringify(payload)); -} - -function sendJsonText(res, payloadText) { - setNoCacheHeaders(res); - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.end(payloadText); -} - -function sendXml(res, payload) { - setNoCacheHeaders(res); - res.statusCode = 200; - res.setHeader('Content-Type', 'text/xml; charset=utf-8'); - res.end(payload); -} - -function sendText(res, payload) { - setNoCacheHeaders(res); - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.end(payload); -} - -function sendNotFound(res) { - setNoCacheHeaders(res); - res.statusCode = 404; - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.end('Not Found'); -} - -module.exports = { - sendHtml, - sendJson, - sendJsonText, - sendNotFound, - sendText, - sendXml, - setNoCacheHeaders, - setStaticCacheHeaders, -}; diff --git a/packages/devextreme/testing/runner/lib/http.ts b/packages/devextreme/testing/runner/lib/http.ts new file mode 100644 index 000000000000..1d20d70838aa --- /dev/null +++ b/packages/devextreme/testing/runner/lib/http.ts @@ -0,0 +1,57 @@ +import { ServerResponse } from 'node:http'; + +export function setNoCacheHeaders(res: ServerResponse): void { + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); +} + +export function setStaticCacheHeaders(res: ServerResponse, searchParams: URLSearchParams): void { + if (searchParams.has('DX_HTTP_CACHE')) { + res.setHeader('Cache-Control', 'public, max-age=31536000'); + } else { + res.setHeader('Cache-Control', 'private, must-revalidate, max-age=0'); + } +} + +export function sendHtml(res: ServerResponse, html: string): void { + setNoCacheHeaders(res); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(html); +} + +export function sendJson(res: ServerResponse, payload: unknown): void { + setNoCacheHeaders(res); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify(payload)); +} + +export function sendJsonText(res: ServerResponse, payloadText: string): void { + setNoCacheHeaders(res); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(payloadText); +} + +export function sendXml(res: ServerResponse, payload: string): void { + setNoCacheHeaders(res); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/xml; charset=utf-8'); + res.end(payload); +} + +export function sendText(res: ServerResponse, payload: string): void { + setNoCacheHeaders(res); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end(payload); +} + +export function sendNotFound(res: ServerResponse): void { + setNoCacheHeaders(res); + res.statusCode = 404; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end('Not Found'); +} diff --git a/packages/devextreme/testing/runner/lib/logger.js b/packages/devextreme/testing/runner/lib/logger.js deleted file mode 100644 index 9e9636af7a1f..000000000000 --- a/packages/devextreme/testing/runner/lib/logger.js +++ /dev/null @@ -1,83 +0,0 @@ -const fs = require('fs'); - -function createRunnerLogger(filePath) { - const rawLogger = createRawLogger(filePath); - - return { - write(message, color) { - const text = String(message || ''); - rawLogger.write(text); - process.stdout.write(colorize(text, color)); - }, - writeLine(message = '', color) { - const text = String(message || ''); - rawLogger.writeLine(text); - process.stdout.write(`${colorize(text, color)}\n`); - }, - writeError(message) { - const text = `ERROR: ${message}`; - rawLogger.writeLine(text); - process.stderr.write(`${text}\n`); - }, - }; -} - -function createRawLogger(filePath) { - return { - filePath, - writeLine(text = '') { - this.write(`${text || ''}\r\n`); - this._time = true; - }, - write(text = '') { - if(!text) { - return; - } - - if(this._time !== false) { - this._time = false; - fs.appendFileSync(this.filePath, `${formatLogTime(new Date())} `, 'utf8'); - } - - fs.appendFileSync(this.filePath, text, 'utf8'); - }, - _time: true, - }; -} - -function formatLogTime(date) { - let hours = date.getHours() % 12; - if(hours === 0) { - hours = 12; - } - - return `${pad2(hours)}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`; -} - -function pad2(value) { - return String(value).padStart(2, '0'); -} - -function colorize(text, color) { - if(!color) { - return text; - } - - const colorCodes = { - red: 31, - green: 32, - yellow: 33, - white: 37, - }; - - const code = colorCodes[color]; - if(!code) { - return text; - } - - return `\u001b[${code}m${text}\u001b[0m`; -} - -module.exports = { - createRunnerLogger, -}; diff --git a/packages/devextreme/testing/runner/lib/logger.ts b/packages/devextreme/testing/runner/lib/logger.ts new file mode 100644 index 000000000000..470e6b669171 --- /dev/null +++ b/packages/devextreme/testing/runner/lib/logger.ts @@ -0,0 +1,88 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import * as fs from 'node:fs'; + +import { RunnerLogColor, RunnerLogger } from './types'; + +interface RawLogger { + filePath: string; + shouldWriteTimePrefix: boolean; + writeLine: (text?: string) => void; + write: (text?: string) => void; +} + +export function createRunnerLogger(filePath: string): RunnerLogger { + const rawLogger = createRawLogger(filePath); + + return { + write(message, color): void { + const text = String(message || ''); + rawLogger.write(text); + process.stdout.write(colorize(text, color)); + }, + writeLine(message, color): void { + const text = String(message || ''); + rawLogger.writeLine(text); + process.stdout.write(`${colorize(text, color)}\n`); + }, + writeError(message: string): void { + const text = `ERROR: ${message}`; + rawLogger.writeLine(text); + process.stderr.write(`${text}\n`); + }, + }; +} + +function createRawLogger(filePath: string): RawLogger { + const logger: RawLogger = { + filePath, + shouldWriteTimePrefix: true, + writeLine(text = '') { + this.write(`${text || ''}\r\n`); + this.shouldWriteTimePrefix = true; + }, + write(text = '') { + if (!text) { + return; + } + + if (this.shouldWriteTimePrefix) { + this.shouldWriteTimePrefix = false; + fs.appendFileSync(this.filePath, `${formatLogTime(new Date())} `, 'utf8'); + } + + fs.appendFileSync(this.filePath, text, 'utf8'); + }, + }; + + return logger; +} + +function formatLogTime(date: Date): string { + let hours = date.getHours() % 12; + if (hours === 0) { + hours = 12; + } + + return `${pad2(hours)}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`; +} + +function pad2(value: number): string { + return String(value).padStart(2, '0'); +} + +function colorize(text: string, color?: RunnerLogColor): string { + if (!color) { + return text; + } + + const colorCodes: Record = { + red: 31, + green: 32, + yellow: 33, + white: 37, + }; + + const code = colorCodes[color]; + + return `\u001b[${code}m${text}\u001b[0m`; +} diff --git a/packages/devextreme/testing/runner/lib/pages.js b/packages/devextreme/testing/runner/lib/pages.js deleted file mode 100644 index 6abdc9b7dd91..000000000000 --- a/packages/devextreme/testing/runner/lib/pages.js +++ /dev/null @@ -1,247 +0,0 @@ -function createPagesRenderer({ - contentWithCacheBuster, - getCacheBuster, - jsonString, - renderTemplate, -}) { - function renderIndexPage() { - return renderTemplate('index.template.html', { - JQUERY_URL: '/packages/devextreme/artifacts/js/jquery.js', - KNOCKOUT_URL: '/packages/devextreme/artifacts/js/knockout-latest.js', - ROOT_URL_JSON: jsonString('/'), - SUITES_JSON_URL_JSON: jsonString('/Main/SuitesJson'), - CATEGORIES_JSON_URL_JSON: jsonString('/Main/CategoriesJson'), - }); - } - - function renderRunAllPage(model, runProps) { - return renderTemplate('run-all.template.html', { - JQUERY_URL: '/packages/devextreme/artifacts/js/jquery.js', - CONSTELLATION_JSON: jsonString(model.Constellation), - CATEGORIES_LIST_JSON: jsonString(model.CategoriesList), - VERSION_JSON: jsonString(model.Version), - SUITES_JSON: jsonString(model.Suites), - NO_TRY_CATCH_JSON: jsonString(runProps.NoTryCatch), - NO_GLOBALS_JSON: jsonString(runProps.NoGlobals), - NO_TIMERS_JSON: jsonString(runProps.NoTimers), - NO_JQUERY_JSON: jsonString(runProps.NoJQuery), - SHADOW_DOM_JSON: jsonString(runProps.ShadowDom), - NO_CSP_JSON: jsonString(runProps.NoCsp), - IS_CONTINUOUS_INTEGRATION_JSON: jsonString(runProps.IsContinuousIntegration), - WORKER_IN_WINDOW_JSON: jsonString(runProps.WorkerInWindow), - MAX_WORKERS_JSON: jsonString(runProps.MaxWorkers), - }); - } - - function renderRunSuitePage(model, runProps, searchParams) { - const scriptVirtualPath = model.ScriptVirtualPath; - const isNoJQueryTest = scriptVirtualPath.includes('nojquery'); - const isServerSideTest = scriptVirtualPath.includes('DevExpress.serverSide'); - const isSelfSufficientTest = scriptVirtualPath.includes('_bundled') - || scriptVirtualPath.includes('Bundles') - || scriptVirtualPath.includes('DevExpress.jquery'); - - const cspPart = runProps.NoCsp ? '' : '-systemjs'; - const npmModule = `transpiled${cspPart}`; - const testingBasePath = runProps.NoCsp - ? '/packages/devextreme/testing/' - : '/packages/devextreme/artifacts/transpiled-testing/'; - - function getJQueryUrl() { - if(isNoJQueryTest) { - return `${testingBasePath}helpers/noJQuery.js`; - } - - return '/packages/devextreme/artifacts/js/jquery.js'; - } - - function getTestUrl() { - if(runProps.NoCsp) { - return scriptVirtualPath; - } - - return scriptVirtualPath.replace('/testing/', '/artifacts/transpiled-testing/'); - } - - function getJQueryIntegrationImports() { - const result = []; - - if(!isSelfSufficientTest) { - if(runProps.NoJQuery || isNoJQueryTest || isServerSideTest) { - result.push(`${testingBasePath}helpers/jQueryEventsPatch.js`); - result.push(`${testingBasePath}helpers/argumentsValidator.js`); - result.push(`${testingBasePath}helpers/dataPatch.js`); - result.push(`/packages/devextreme/artifacts/${npmModule}/__internal/integration/jquery/component_registrator.js`); - } else { - result.push(`/packages/devextreme/artifacts/${npmModule}/integration/jquery.js`); - } - } - - if(isServerSideTest) { - result.push(`${testingBasePath}helpers/ssrEmulator.js`); - } - - return result; - } - - const cacheBuster = getCacheBuster(searchParams); - - const qunitCss = contentWithCacheBuster('/packages/devextreme/node_modules/qunit/qunit/qunit.css', cacheBuster); - const qunitJs = contentWithCacheBuster('/packages/devextreme/node_modules/qunit/qunit/qunit.js', cacheBuster); - 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 cspMap = !runProps.NoCsp - ? { - 'inferno-create-element': '/packages/devextreme/node_modules/inferno-create-element/dist/inferno-create-element.js', - intl: '/packages/devextreme/artifacts/js-systemjs/intl/index.js', - knockout: '/packages/devextreme/artifacts/js-systemjs/knockout.js', - css: '/packages/devextreme/artifacts/js-systemjs/css.js', - 'generic_light.css': '/packages/devextreme/artifacts/css-systemjs/dx.light.css', - 'material_blue_light.css': '/packages/devextreme/artifacts/css-systemjs/dx.material.blue.light.css', - 'fluent_blue_light.css': '/packages/devextreme/artifacts/css-systemjs/dx.fluent.blue.light.css', - 'gantt.css': '/packages/devextreme/artifacts/css-systemjs/dx-gantt.css', - 'devextreme-cldr-data': '/packages/devextreme/artifacts/js-systemjs/devextreme-cldr-data', - 'cldr-core': '/packages/devextreme/artifacts/js-systemjs/cldr-core', - json: '/packages/devextreme/artifacts/js-systemjs/json.js', - '@preact/signals-core': '/packages/devextreme/artifacts/js-systemjs/preact-signals.js', - } - : { - 'devextreme-cldr-data': '/packages/devextreme/node_modules/devextreme-cldr-data', - 'cldr-core': '/packages/devextreme/node_modules/cldr-core', - '@preact/signals-core': '/packages/devextreme/node_modules/@preact/signals-core/dist/signals-core.js', - }; - - const systemMap = { - globalize: '/packages/devextreme/node_modules/globalize/dist/globalize', - intl: '/packages/devextreme/node_modules/intl/index.js', - cldr: '/packages/devextreme/node_modules/cldrjs/dist/cldr', - jquery: getJQueryUrl(), - knockout: '/packages/devextreme/node_modules/knockout/build/output/knockout-latest.debug.js', - jszip: '/packages/devextreme/artifacts/js/jszip.js', - underscore: '/packages/devextreme/node_modules/underscore/underscore-min.js', - '@@devextreme/vdom': '/packages/devextreme/node_modules/@devextreme/vdom', - 'devextreme-quill': '/packages/devextreme/node_modules/devextreme-quill/dist/dx-quill.js', - 'devexpress-diagram': '/packages/devextreme/artifacts/js/dx-diagram.js', - 'devexpress-gantt': '/packages/devextreme/artifacts/js/dx-gantt.js', - 'devextreme-exceljs-fork': '/packages/devextreme/node_modules/devextreme-exceljs-fork/dist/dx-exceljs-fork.js', - 'fflate': '/packages/devextreme/node_modules/fflate/esm/browser.js', - jspdf: '/packages/devextreme/node_modules/jspdf/dist/jspdf.umd.js', - 'jspdf-autotable': '/packages/devextreme/node_modules/jspdf-autotable/dist/jspdf.plugin.autotable.js', - rrule: '/packages/devextreme/node_modules/rrule/dist/es5/rrule.js', - inferno: '/packages/devextreme/node_modules/inferno/dist/inferno.js', - 'inferno-hydrate': '/packages/devextreme/node_modules/inferno-hydrate/dist/inferno-hydrate.js', - 'inferno-compat': '/packages/devextreme/node_modules/inferno-compat/dist/inferno-compat.js', - 'inferno-clone-vnode': '/packages/devextreme/node_modules/inferno-clone-vnode/dist/index.cjs.js', - 'inferno-create-element': '/packages/devextreme/node_modules/inferno-create-element/dist/index.cjs.js', - 'inferno-create-class': '/packages/devextreme/node_modules/inferno-create-class/dist/index.cjs.js', - 'inferno-extras': '/packages/devextreme/node_modules/inferno-extras/dist/index.cjs.js', - 'generic_light.css': '/packages/devextreme/artifacts/css/dx.light.css', - 'material_blue_light.css': '/packages/devextreme/artifacts/css/dx.material.blue.light.css', - 'fluent_blue_light.css': '/packages/devextreme/artifacts/css/dx.fluent.blue.light.css', - 'gantt.css': '/packages/devextreme/artifacts/css/dx-gantt.css', - css: '/packages/devextreme/node_modules/systemjs-plugin-css/css.js', - text: '/packages/devextreme/node_modules/systemjs-plugin-text/text.js', - json: '/packages/devextreme/node_modules/systemjs-plugin-json/json.js', - 'plugin-babel': '/packages/devextreme/node_modules/systemjs-plugin-babel/plugin-babel.js', - 'systemjs-babel-build': '/packages/devextreme/node_modules/systemjs-plugin-babel/systemjs-babel-browser.js', - ...cspMap, - }; - - const systemPackages = { - '': { - defaultExtension: 'js', - }, - globalize: { - main: '../globalize.js', - defaultExtension: 'js', - }, - cldr: { - main: '../cldr.js', - defaultExtension: 'js', - }, - 'common/core/events/utils': { - main: 'index', - }, - 'events/utils': { - main: 'index', - }, - events: { - main: 'index', - }, - }; - - const knockoutPath = '/packages/devextreme/node_modules/knockout/build/output/knockout-latest.debug.js'; - - const systemConfig = { - baseURL: `/packages/devextreme/artifacts/${npmModule}`, - transpiler: 'plugin-babel', - map: systemMap, - packages: systemPackages, - packageConfigPaths: [ - '@@devextreme/*/package.json', - ], - meta: { - [knockoutPath]: { - format: 'global', - deps: ['jquery'], - exports: 'ko', - }, - '*.js': { - babelOptions: { - es2015: false, - }, - }, - }, - }; - - const integrationImportPaths = getJQueryIntegrationImports(); - const cspMetaTag = runProps.NoCsp - ? '' - : ``; - - return renderTemplate('run-suite.template.html', { - CSP_META_TAG: cspMetaTag, - TITLE: model.Title, - QUNIT_CSS_URL: qunitCss, - QUNIT_JS_URL: qunitJs, - QUNIT_EXTENSIONS_JS_URL: qunitExtensionsJs, - JQUERY_JS_URL: jqueryJs, - SINON_JS_URL: sinonJs, - SYSTEM_JS_URL: systemJs, - IS_CONTINUOUS_INTEGRATION_JSON: jsonString(runProps.IsContinuousIntegration), - CACHE_BUSTER_JSON: jsonString(cacheBuster), - SYSTEM_CONFIG_JSON: jsonString(systemConfig), - INTEGRATION_IMPORT_PATHS_JSON: jsonString(integrationImportPaths), - IS_SERVER_SIDE_TEST_JSON: jsonString(isServerSideTest), - TEST_URL_JSON: jsonString(getTestUrl()), - }); - } - - return { - renderIndexPage, - renderRunAllPage, - renderRunSuitePage, - }; -} - -module.exports = { - createPagesRenderer, -}; diff --git a/packages/devextreme/testing/runner/lib/pages.ts b/packages/devextreme/testing/runner/lib/pages.ts new file mode 100644 index 000000000000..7abc68ac899a --- /dev/null +++ b/packages/devextreme/testing/runner/lib/pages.ts @@ -0,0 +1,269 @@ +import { + BaseRunProps, RunAllModel, RunSuiteModel, TemplateVars, +} from './types'; + +interface PagesRendererDeps { + contentWithCacheBuster: (contentPath: string, cacheBuster: string) => string; + getCacheBuster: (searchParams: URLSearchParams) => string; + jsonString: (value: unknown) => string; + renderTemplate: (templateName: string, vars?: TemplateVars) => string; +} + +export interface PagesRenderer { + renderIndexPage: () => string; + renderRunAllPage: (model: RunAllModel, runProps: BaseRunProps) => string; + renderRunSuitePage: ( + model: RunSuiteModel, + runProps: BaseRunProps, + searchParams: URLSearchParams, + ) => string; +} + +export function createPagesRenderer({ + contentWithCacheBuster, + getCacheBuster, + jsonString, + renderTemplate, +}: PagesRendererDeps): PagesRenderer { + function renderIndexPage(): string { + return renderTemplate('index.template.html', { + JQUERY_URL: '/packages/devextreme/artifacts/js/jquery.js', + KNOCKOUT_URL: '/packages/devextreme/artifacts/js/knockout-latest.js', + ROOT_URL_JSON: jsonString('/'), + SUITES_JSON_URL_JSON: jsonString('/Main/SuitesJson'), + CATEGORIES_JSON_URL_JSON: jsonString('/Main/CategoriesJson'), + }); + } + + function renderRunAllPage(model: RunAllModel, runProps: BaseRunProps): string { + return renderTemplate('run-all.template.html', { + JQUERY_URL: '/packages/devextreme/artifacts/js/jquery.js', + CONSTELLATION_JSON: jsonString(model.Constellation), + CATEGORIES_LIST_JSON: jsonString(model.CategoriesList), + VERSION_JSON: jsonString(model.Version), + SUITES_JSON: jsonString(model.Suites), + NO_TRY_CATCH_JSON: jsonString(runProps.NoTryCatch), + NO_GLOBALS_JSON: jsonString(runProps.NoGlobals), + NO_TIMERS_JSON: jsonString(runProps.NoTimers), + NO_JQUERY_JSON: jsonString(runProps.NoJQuery), + SHADOW_DOM_JSON: jsonString(runProps.ShadowDom), + NO_CSP_JSON: jsonString(runProps.NoCsp), + IS_CONTINUOUS_INTEGRATION_JSON: jsonString(runProps.IsContinuousIntegration), + WORKER_IN_WINDOW_JSON: jsonString(runProps.WorkerInWindow), + MAX_WORKERS_JSON: jsonString(runProps.MaxWorkers), + }); + } + + function renderRunSuitePage( + model: RunSuiteModel, + runProps: BaseRunProps, + searchParams: URLSearchParams, + ): string { + const scriptVirtualPath = model.ScriptVirtualPath; + const isNoJQueryTest = scriptVirtualPath.includes('nojquery'); + const isServerSideTest = scriptVirtualPath.includes('DevExpress.serverSide'); + const isSelfSufficientTest = scriptVirtualPath.includes('_bundled') + || scriptVirtualPath.includes('Bundles') + || scriptVirtualPath.includes('DevExpress.jquery'); + + const cspPart = runProps.NoCsp ? '' : '-systemjs'; + const npmModule = `transpiled${cspPart}`; + const testingBasePath = runProps.NoCsp + ? '/packages/devextreme/testing/' + : '/packages/devextreme/artifacts/transpiled-testing/'; + + function getJQueryUrl(): string { + if (isNoJQueryTest) { + return `${testingBasePath}helpers/noJQuery.js`; + } + + return '/packages/devextreme/artifacts/js/jquery.js'; + } + + function getTestUrl(): string { + if (runProps.NoCsp) { + return scriptVirtualPath; + } + + return scriptVirtualPath.replace('/testing/', '/artifacts/transpiled-testing/'); + } + + function getJQueryIntegrationImports(): string[] { + const result: string[] = []; + + if (!isSelfSufficientTest) { + if (runProps.NoJQuery || isNoJQueryTest || isServerSideTest) { + result.push(`${testingBasePath}helpers/jQueryEventsPatch.js`); + result.push(`${testingBasePath}helpers/argumentsValidator.js`); + result.push(`${testingBasePath}helpers/dataPatch.js`); + result.push(`/packages/devextreme/artifacts/${npmModule}/__internal/integration/jquery/component_registrator.js`); + } else { + result.push(`/packages/devextreme/artifacts/${npmModule}/integration/jquery.js`); + } + } + + if (isServerSideTest) { + result.push(`${testingBasePath}helpers/ssrEmulator.js`); + } + + return result; + } + + const cacheBuster = getCacheBuster(searchParams); + + const qunitCss = contentWithCacheBuster('/packages/devextreme/node_modules/qunit/qunit/qunit.css', cacheBuster); + const qunitJs = contentWithCacheBuster('/packages/devextreme/node_modules/qunit/qunit/qunit.js', cacheBuster); + 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 cspMap: Record = !runProps.NoCsp + ? { + 'inferno-create-element': '/packages/devextreme/node_modules/inferno-create-element/dist/inferno-create-element.js', + intl: '/packages/devextreme/artifacts/js-systemjs/intl/index.js', + knockout: '/packages/devextreme/artifacts/js-systemjs/knockout.js', + css: '/packages/devextreme/artifacts/js-systemjs/css.js', + 'generic_light.css': '/packages/devextreme/artifacts/css-systemjs/dx.light.css', + 'material_blue_light.css': '/packages/devextreme/artifacts/css-systemjs/dx.material.blue.light.css', + 'fluent_blue_light.css': '/packages/devextreme/artifacts/css-systemjs/dx.fluent.blue.light.css', + 'gantt.css': '/packages/devextreme/artifacts/css-systemjs/dx-gantt.css', + 'devextreme-cldr-data': '/packages/devextreme/artifacts/js-systemjs/devextreme-cldr-data', + 'cldr-core': '/packages/devextreme/artifacts/js-systemjs/cldr-core', + json: '/packages/devextreme/artifacts/js-systemjs/json.js', + '@preact/signals-core': '/packages/devextreme/artifacts/js-systemjs/preact-signals.js', + } + : { + 'devextreme-cldr-data': '/packages/devextreme/node_modules/devextreme-cldr-data', + 'cldr-core': '/packages/devextreme/node_modules/cldr-core', + '@preact/signals-core': '/packages/devextreme/node_modules/@preact/signals-core/dist/signals-core.js', + }; + + const systemMap: Record = { + globalize: '/packages/devextreme/node_modules/globalize/dist/globalize', + intl: '/packages/devextreme/node_modules/intl/index.js', + cldr: '/packages/devextreme/node_modules/cldrjs/dist/cldr', + jquery: getJQueryUrl(), + knockout: '/packages/devextreme/node_modules/knockout/build/output/knockout-latest.debug.js', + jszip: '/packages/devextreme/artifacts/js/jszip.js', + underscore: '/packages/devextreme/node_modules/underscore/underscore-min.js', + '@@devextreme/vdom': '/packages/devextreme/node_modules/@devextreme/vdom', + 'devextreme-quill': '/packages/devextreme/node_modules/devextreme-quill/dist/dx-quill.js', + 'devexpress-diagram': '/packages/devextreme/artifacts/js/dx-diagram.js', + 'devexpress-gantt': '/packages/devextreme/artifacts/js/dx-gantt.js', + 'devextreme-exceljs-fork': '/packages/devextreme/node_modules/devextreme-exceljs-fork/dist/dx-exceljs-fork.js', + // eslint-disable-next-line @stylistic/quote-props + 'fflate': '/packages/devextreme/node_modules/fflate/esm/browser.js', + jspdf: '/packages/devextreme/node_modules/jspdf/dist/jspdf.umd.js', + 'jspdf-autotable': '/packages/devextreme/node_modules/jspdf-autotable/dist/jspdf.plugin.autotable.js', + rrule: '/packages/devextreme/node_modules/rrule/dist/es5/rrule.js', + inferno: '/packages/devextreme/node_modules/inferno/dist/inferno.js', + 'inferno-hydrate': '/packages/devextreme/node_modules/inferno-hydrate/dist/inferno-hydrate.js', + 'inferno-compat': '/packages/devextreme/node_modules/inferno-compat/dist/inferno-compat.js', + 'inferno-clone-vnode': '/packages/devextreme/node_modules/inferno-clone-vnode/dist/index.cjs.js', + 'inferno-create-element': '/packages/devextreme/node_modules/inferno-create-element/dist/index.cjs.js', + 'inferno-create-class': '/packages/devextreme/node_modules/inferno-create-class/dist/index.cjs.js', + 'inferno-extras': '/packages/devextreme/node_modules/inferno-extras/dist/index.cjs.js', + 'generic_light.css': '/packages/devextreme/artifacts/css/dx.light.css', + 'material_blue_light.css': '/packages/devextreme/artifacts/css/dx.material.blue.light.css', + 'fluent_blue_light.css': '/packages/devextreme/artifacts/css/dx.fluent.blue.light.css', + 'gantt.css': '/packages/devextreme/artifacts/css/dx-gantt.css', + css: '/packages/devextreme/node_modules/systemjs-plugin-css/css.js', + text: '/packages/devextreme/node_modules/systemjs-plugin-text/text.js', + json: '/packages/devextreme/node_modules/systemjs-plugin-json/json.js', + 'plugin-babel': '/packages/devextreme/node_modules/systemjs-plugin-babel/plugin-babel.js', + 'systemjs-babel-build': '/packages/devextreme/node_modules/systemjs-plugin-babel/systemjs-babel-browser.js', + ...cspMap, + }; + + const systemPackages: Record = { + '': { + defaultExtension: 'js', + }, + globalize: { + main: '../globalize.js', + defaultExtension: 'js', + }, + cldr: { + main: '../cldr.js', + defaultExtension: 'js', + }, + 'common/core/events/utils': { + main: 'index', + }, + 'events/utils': { + main: 'index', + }, + events: { + main: 'index', + }, + }; + + const knockoutPath = '/packages/devextreme/node_modules/knockout/build/output/knockout-latest.debug.js'; + + const systemConfig = { + baseURL: `/packages/devextreme/artifacts/${npmModule}`, + transpiler: 'plugin-babel', + map: systemMap, + packages: systemPackages, + packageConfigPaths: [ + '@@devextreme/*/package.json', + ], + meta: { + [knockoutPath]: { + format: 'global', + deps: ['jquery'], + exports: 'ko', + }, + '*.js': { + babelOptions: { + es2015: false, + }, + }, + }, + }; + + const integrationImportPaths = getJQueryIntegrationImports(); + const cspMetaTag = runProps.NoCsp + ? '' + : ``; + + return renderTemplate('run-suite.template.html', { + CSP_META_TAG: cspMetaTag, + TITLE: model.Title, + QUNIT_CSS_URL: qunitCss, + QUNIT_JS_URL: qunitJs, + QUNIT_EXTENSIONS_JS_URL: qunitExtensionsJs, + JQUERY_JS_URL: jqueryJs, + SINON_JS_URL: sinonJs, + SYSTEM_JS_URL: systemJs, + IS_CONTINUOUS_INTEGRATION_JSON: jsonString(runProps.IsContinuousIntegration), + CACHE_BUSTER_JSON: jsonString(cacheBuster), + SYSTEM_CONFIG_JSON: jsonString(systemConfig), + INTEGRATION_IMPORT_PATHS_JSON: jsonString(integrationImportPaths), + IS_SERVER_SIDE_TEST_JSON: jsonString(isServerSideTest), + TEST_URL_JSON: jsonString(getTestUrl()), + }); + } + + return { + renderIndexPage, + renderRunAllPage, + renderRunSuitePage, + }; +} diff --git a/packages/devextreme/testing/runner/lib/results.js b/packages/devextreme/testing/runner/lib/results.js deleted file mode 100644 index cdee8262fa91..000000000000 --- a/packages/devextreme/testing/runner/lib/results.js +++ /dev/null @@ -1,161 +0,0 @@ -function createResultsReporter({ - escapeXmlAttr, - escapeXmlText, - normalizeNumber, -}) { - function validateResultsJson(json) { - const badToken = '\\u0000'; - const badIndex = json.indexOf(badToken); - - if(badIndex > -1) { - const from = Math.max(0, badIndex - 200); - const to = Math.min(json.length, badIndex + 200); - throw new Error(`Result JSON has bad content: ${json.slice(from, to)}`); - } - } - - function printTextReport(results, writeLine) { - const maxWrittenFailures = 50; - const notRunCases = []; - const failedCases = []; - - (results.suites || []).forEach((suite) => { - enumerateAllCases(suite, (testCase) => { - if(testCase && testCase.reason) { - notRunCases.push(testCase); - } - if(testCase && testCase.failure) { - failedCases.push(testCase); - } - }); - }); - - const total = Number(results.total) || 0; - const failures = Number(results.failures) || 0; - const notRunCount = notRunCases.length; - const color = failures > 0 ? 'red' : (notRunCount > 0 ? 'yellow' : 'green'); - - writeLine(`Tests run: ${total}, Failures: ${failures}, Not run: ${notRunCount}`, color); - - if(notRunCount > 0 && failures === 0) { - notRunCases.forEach((testCase) => { - writeLine('-'.repeat(80)); - writeLine(`Skipped: ${testCase.name || ''}`); - writeLine(`Reason: ${testCase.reason && testCase.reason.message ? testCase.reason.message : ''}`); - }); - } - - if(failures > 0) { - let writtenFailures = 0; - - failedCases.forEach((testCase) => { - if(writtenFailures >= maxWrittenFailures) { - return; - } - - writeLine('-'.repeat(80)); - writeLine(testCase.name || '', 'white'); - writeLine(); - writeLine(testCase.failure && testCase.failure.message ? testCase.failure.message : ''); - - writtenFailures += 1; - }); - - if(writtenFailures >= maxWrittenFailures) { - writeLine(`WARNING: only first ${maxWrittenFailures} failures are shown.`); - } - } - } - - function testResultsToXml(results) { - const lines = []; - - lines.push(``); - - (results.suites || []).forEach((suite) => { - lines.push(renderSuiteXml(suite, ' ')); - }); - - lines.push(''); - - return `${lines.join('\n')}\n`; - } - - function renderSuiteXml(suite, indent) { - const lines = []; - - lines.push(`${indent}`); - lines.push(`${indent} `); - - (suite.results || []).forEach((item) => { - if(item && Array.isArray(item.results)) { - lines.push(renderSuiteXml(item, `${indent} `)); - } else { - lines.push(renderCaseXml(item || {}, `${indent} `)); - } - }); - - lines.push(`${indent} `); - lines.push(`${indent}`); - - return lines.join('\n'); - } - - function renderCaseXml(testCase, indent) { - const attributes = [ - `name="${escapeXmlAttr(testCase.name || '')}"`, - `url="${escapeXmlAttr(testCase.url || '')}"`, - `time="${escapeXmlAttr(testCase.time || '')}"`, - ]; - - if(testCase.executed === false) { - attributes.push('executed="false"'); - } - - const hasFailure = Boolean(testCase.failure && typeof testCase.failure.message === 'string'); - const hasReason = Boolean(testCase.reason && typeof testCase.reason.message === 'string'); - - if(!hasFailure && !hasReason) { - return `${indent}`; - } - - const lines = [`${indent}`]; - - if(hasFailure) { - lines.push(`${indent} `); - lines.push(`${indent} ${escapeXmlText(testCase.failure.message)}`); - lines.push(`${indent} `); - } - - if(hasReason) { - lines.push(`${indent} `); - lines.push(`${indent} ${escapeXmlText(testCase.reason.message)}`); - lines.push(`${indent} `); - } - - lines.push(`${indent}`); - - return lines.join('\n'); - } - - return { - printTextReport, - testResultsToXml, - validateResultsJson, - }; -} - -function enumerateAllCases(suite, callback) { - (suite.results || []).forEach((item) => { - if(item && Array.isArray(item.results)) { - enumerateAllCases(item, callback); - return; - } - - callback(item); - }); -} - -module.exports = { - createResultsReporter, -}; diff --git a/packages/devextreme/testing/runner/lib/results.ts b/packages/devextreme/testing/runner/lib/results.ts new file mode 100644 index 000000000000..832448751fc4 --- /dev/null +++ b/packages/devextreme/testing/runner/lib/results.ts @@ -0,0 +1,196 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import { + RunnerLogColor, + TestCaseResult, + TestResultItem, + TestResultsPayload, + TestSuiteResult, +} from './types'; + +interface ResultsReporterDeps { + escapeXmlAttr: (value: unknown) => string; + escapeXmlText: (value: unknown) => string; + normalizeNumber: (value: unknown) => number; +} + +export interface ResultsReporter { + printTextReport: ( + results: TestResultsPayload, + writeLine: (message?: string, color?: RunnerLogColor) => void, + ) => void; + testResultsToXml: (results: TestResultsPayload) => string; + validateResultsJson: (json: string) => void; +} + +export function createResultsReporter({ + escapeXmlAttr, + escapeXmlText, + normalizeNumber, +}: ResultsReporterDeps): ResultsReporter { + function validateResultsJson(json: string): void { + const badToken = '\\u0000'; + const badIndex = json.indexOf(badToken); + + if (badIndex > -1) { + const from = Math.max(0, badIndex - 200); + const to = Math.min(json.length, badIndex + 200); + throw new Error(`Result JSON has bad content: ${json.slice(from, to)}`); + } + } + + function printTextReport( + results: TestResultsPayload, + writeLine: (message?: string, color?: RunnerLogColor) => void, + ): void { + const maxWrittenFailures = 50; + const notRunCases: TestCaseResult[] = []; + const failedCases: TestCaseResult[] = []; + + (results.suites || []).forEach((suite) => { + enumerateAllCases(suite, (testCase) => { + if (testCase.reason) { + notRunCases.push(testCase); + } + if (testCase.failure) { + failedCases.push(testCase); + } + }); + }); + + const total = Number(results.total) || 0; + const failures = Number(results.failures) || 0; + const notRunCount = notRunCases.length; + let color: RunnerLogColor = 'green'; + if (failures > 0) { + color = 'red'; + } else if (notRunCount > 0) { + color = 'yellow'; + } + + writeLine(`Tests run: ${total}, Failures: ${failures}, Not run: ${notRunCount}`, color); + + if (notRunCount > 0 && failures === 0) { + notRunCases.forEach((testCase) => { + writeLine('-'.repeat(80)); + writeLine(`Skipped: ${testCase.name || ''}`); + writeLine(`Reason: ${testCase.reason?.message || ''}`); + }); + } + + if (failures > 0) { + let writtenFailures = 0; + + failedCases.forEach((testCase) => { + if (writtenFailures >= maxWrittenFailures) { + return; + } + + writeLine('-'.repeat(80)); + writeLine(testCase.name || '', 'white'); + writeLine(); + writeLine(testCase.failure?.message || ''); + + writtenFailures += 1; + }); + + if (writtenFailures >= maxWrittenFailures) { + writeLine(`WARNING: only first ${maxWrittenFailures} failures are shown.`); + } + } + } + + function testResultsToXml(results: TestResultsPayload): string { + const lines: string[] = []; + + lines.push(``); + + (results.suites || []).forEach((suite) => { + lines.push(renderSuiteXml(suite, ' ')); + }); + + lines.push(''); + + return `${lines.join('\n')}\n`; + } + + function renderSuiteXml(suite: TestSuiteResult, indent: string): string { + const lines: string[] = []; + + lines.push(`${indent}`); + lines.push(`${indent} `); + + (suite.results || []).forEach((item) => { + if (isSuiteResultItem(item)) { + lines.push(renderSuiteXml(item, `${indent} `)); + } else { + lines.push(renderCaseXml(item || {}, `${indent} `)); + } + }); + + lines.push(`${indent} `); + lines.push(`${indent}`); + + return lines.join('\n'); + } + + function renderCaseXml(testCase: TestCaseResult, indent: string): string { + const attributes = [ + `name="${escapeXmlAttr(testCase.name || '')}"`, + `url="${escapeXmlAttr(testCase.url || '')}"`, + `time="${escapeXmlAttr(testCase.time || '')}"`, + ]; + + if (testCase.executed === false) { + attributes.push('executed="false"'); + } + + const hasFailure = typeof testCase.failure?.message === 'string'; + const hasReason = typeof testCase.reason?.message === 'string'; + + if (!hasFailure && !hasReason) { + return `${indent}`; + } + + const lines = [`${indent}`]; + + if (hasFailure) { + lines.push(`${indent} `); + lines.push(`${indent} ${escapeXmlText(testCase.failure?.message || '')}`); + lines.push(`${indent} `); + } + + if (hasReason) { + lines.push(`${indent} `); + lines.push(`${indent} ${escapeXmlText(testCase.reason?.message || '')}`); + lines.push(`${indent} `); + } + + lines.push(`${indent}`); + + return lines.join('\n'); + } + + return { + printTextReport, + testResultsToXml, + validateResultsJson, + }; +} + +function enumerateAllCases( + suite: TestSuiteResult, + callback: (testCase: TestCaseResult) => void, +): void { + (suite.results || []).forEach((item) => { + if (isSuiteResultItem(item)) { + enumerateAllCases(item, callback); + return; + } + + callback(item || {}); + }); +} + +function isSuiteResultItem(item: TestResultItem): item is TestSuiteResult { + return Array.isArray((item as TestSuiteResult).results); +} diff --git a/packages/devextreme/testing/runner/lib/static.js b/packages/devextreme/testing/runner/lib/static.js deleted file mode 100644 index d904245222bc..000000000000 --- a/packages/devextreme/testing/runner/lib/static.js +++ /dev/null @@ -1,165 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -function createStaticFileService({ - escapeHtml, - rootDirectory, - setNoCacheHeaders, - setStaticCacheHeaders, -}) { - function tryServeStatic(req, res, pathname, searchParams) { - const normalizedPath = pathname === '/' ? '/' : pathname.replace(/\/+$/, ''); - const relativePath = normalizedPath.replace(/^\/+/, ''); - const filePath = path.resolve(path.join(rootDirectory, relativePath)); - const relativeToRoot = path.relative(rootDirectory, filePath); - - if(relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) { - setNoCacheHeaders(res); - res.statusCode = 403; - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.end('Forbidden'); - return true; - } - - if(!fs.existsSync(filePath)) { - return false; - } - - setStaticCacheHeaders(res, searchParams); - - const stat = fs.statSync(filePath); - - if(stat.isDirectory()) { - return sendDirectoryListing(res, pathname, filePath); - } - - if(stat.isFile()) { - return sendStaticFile(res, filePath, stat.size); - } - - return false; - } - - function sendStaticFile(res, filePath, fileSize) { - res.statusCode = 200; - res.setHeader('Content-Type', getContentType(filePath)); - res.setHeader('Content-Length', String(fileSize)); - - const stream = fs.createReadStream(filePath); - stream.pipe(res); - - stream.on('error', () => { - if(!res.headersSent) { - res.statusCode = 500; - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - } - if(!res.writableEnded) { - res.end('Internal Server Error'); - } - }); - - return true; - } - - function sendDirectoryListing(res, requestPath, dirPath) { - const entries = fs.readdirSync(dirPath, { withFileTypes: true }); - const pathname = requestPath.endsWith('/') ? requestPath : `${requestPath}/`; - - const items = []; - - if(pathname !== '/') { - const parentPath = pathname - .split('/') - .filter(Boolean) - .slice(0, -1) - .join('/'); - const href = parentPath ? `/${parentPath}/` : '/'; - items.push(`
  • ..
  • `); - } - - entries - .sort((a, b) => a.name.localeCompare(b.name)) - .forEach((entry) => { - const suffix = entry.isDirectory() ? '/' : ''; - const href = `${pathname}${encodeURIComponent(entry.name)}${suffix}`; - items.push(`
  • ${escapeHtml(entry.name)}${suffix}
  • `); - }); - - const html = ` - - - -Index of ${escapeHtml(pathname)} - - -

    Index of ${escapeHtml(pathname)}

    -
      -${items.join('\n')} -
    - -`; - - res.statusCode = 200; - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(html); - - return true; - } - - return { - tryServeStatic, - }; -} - -function getContentType(filePath) { - const ext = path.extname(filePath).toLowerCase(); - - switch(ext) { - case '.html': - case '.htm': - return 'text/html; charset=utf-8'; - case '.css': - return 'text/css; charset=utf-8'; - case '.js': - case '.mjs': - return 'application/javascript; charset=utf-8'; - case '.json': - return 'application/json; charset=utf-8'; - case '.xml': - case '.xsl': - return 'text/xml; charset=utf-8'; - case '.txt': - case '.md': - case '.log': - return 'text/plain; charset=utf-8'; - case '.svg': - return 'image/svg+xml'; - case '.png': - return 'image/png'; - case '.jpg': - case '.jpeg': - return 'image/jpeg'; - case '.gif': - return 'image/gif'; - case '.ico': - return 'image/x-icon'; - case '.woff': - return 'font/woff'; - case '.woff2': - return 'font/woff2'; - case '.ttf': - return 'font/ttf'; - case '.eot': - return 'application/vnd.ms-fontobject'; - case '.map': - return 'application/json; charset=utf-8'; - case '.wasm': - return 'application/wasm'; - default: - return 'application/octet-stream'; - } -} - -module.exports = { - createStaticFileService, -}; diff --git a/packages/devextreme/testing/runner/lib/static.ts b/packages/devextreme/testing/runner/lib/static.ts new file mode 100644 index 000000000000..8bbd5534be1f --- /dev/null +++ b/packages/devextreme/testing/runner/lib/static.ts @@ -0,0 +1,188 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import * as fs from 'node:fs'; +import { IncomingMessage, ServerResponse } from 'node:http'; +import * as path from 'node:path'; + +interface StaticFileServiceDeps { + escapeHtml: (value: unknown) => string; + rootDirectory: string; + setNoCacheHeaders: (res: ServerResponse) => void; + setStaticCacheHeaders: (res: ServerResponse, searchParams: URLSearchParams) => void; +} + +export interface StaticFileService { + tryServeStatic: ( + req: IncomingMessage, + res: ServerResponse, + pathname: string, + searchParams: URLSearchParams, + ) => boolean; +} + +export function createStaticFileService({ + escapeHtml, + rootDirectory, + setNoCacheHeaders, + setStaticCacheHeaders, +}: StaticFileServiceDeps): StaticFileService { + function tryServeStatic( + _req: IncomingMessage, + res: ServerResponse, + pathname: string, + searchParams: URLSearchParams, + ): boolean { + const normalizedPath = pathname === '/' ? '/' : pathname.replace(/\/+$/, ''); + const relativePath = normalizedPath.replace(/^\/+/, ''); + const filePath = path.resolve(path.join(rootDirectory, relativePath)); + const relativeToRoot = path.relative(rootDirectory, filePath); + + if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) { + setNoCacheHeaders(res); + res.statusCode = 403; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end('Forbidden'); + return true; + } + + if (!fs.existsSync(filePath)) { + return false; + } + + setStaticCacheHeaders(res, searchParams); + + const stat = fs.statSync(filePath); + + if (stat.isDirectory()) { + return sendDirectoryListing(res, pathname, filePath); + } + + if (stat.isFile()) { + return sendStaticFile(res, filePath, stat.size); + } + + return false; + } + + function sendStaticFile(res: ServerResponse, filePath: string, fileSize: number): boolean { + res.statusCode = 200; + res.setHeader('Content-Type', getContentType(filePath)); + res.setHeader('Content-Length', String(fileSize)); + + const stream = fs.createReadStream(filePath); + stream.pipe(res); + + stream.on('error', () => { + if (!res.headersSent) { + res.statusCode = 500; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + } + if (!res.writableEnded) { + res.end('Internal Server Error'); + } + }); + + return true; + } + + function sendDirectoryListing( + res: ServerResponse, + requestPath: string, + dirPath: string, + ): boolean { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + const pathname = requestPath.endsWith('/') ? requestPath : `${requestPath}/`; + + const items: string[] = []; + + if (pathname !== '/') { + const parentPath = pathname + .split('/') + .filter(Boolean) + .slice(0, -1) + .join('/'); + const href = parentPath ? `/${parentPath}/` : '/'; + items.push(`
  • ..
  • `); + } + + entries + .sort((a, b) => a.name.localeCompare(b.name)) + .forEach((entry) => { + const suffix = entry.isDirectory() ? '/' : ''; + const href = `${pathname}${encodeURIComponent(entry.name)}${suffix}`; + items.push(`
  • ${escapeHtml(entry.name)}${suffix}
  • `); + }); + + const html = ` + + + +Index of ${escapeHtml(pathname)} + + +

    Index of ${escapeHtml(pathname)}

    +
      +${items.join('\n')} +
    + +`; + + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(html); + + return true; + } + + return { + tryServeStatic, + }; +} + +function getContentType(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + + switch (ext) { + case '.html': + case '.htm': + return 'text/html; charset=utf-8'; + case '.css': + return 'text/css; charset=utf-8'; + case '.js': + case '.mjs': + return 'application/javascript; charset=utf-8'; + case '.json': + return 'application/json; charset=utf-8'; + case '.xml': + case '.xsl': + return 'text/xml; charset=utf-8'; + case '.txt': + case '.md': + case '.log': + return 'text/plain; charset=utf-8'; + case '.svg': + return 'image/svg+xml'; + case '.png': + return 'image/png'; + case '.jpg': + case '.jpeg': + return 'image/jpeg'; + case '.gif': + return 'image/gif'; + case '.ico': + return 'image/x-icon'; + case '.woff': + return 'font/woff'; + case '.woff2': + return 'font/woff2'; + case '.ttf': + return 'font/ttf'; + case '.eot': + return 'application/vnd.ms-fontobject'; + case '.map': + return 'application/json; charset=utf-8'; + case '.wasm': + return 'application/wasm'; + default: + return 'application/octet-stream'; + } +} diff --git a/packages/devextreme/testing/runner/lib/suites.js b/packages/devextreme/testing/runner/lib/suites.js deleted file mode 100644 index f046d2154644..000000000000 --- a/packages/devextreme/testing/runner/lib/suites.js +++ /dev/null @@ -1,156 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -function createSuitesService({ - knownConstellations, - testsRoot, -}) { - function readCategories() { - const dirs = fs.readdirSync(testsRoot, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(testsRoot, entry.name)) - .filter(isNotEmptyDir) - .map(categoryFromPath) - .sort((a, b) => a.Name.localeCompare(b.Name)); - - return dirs; - } - - function readSuites(catName) { - if(!catName) { - throw new Error('Category name is required.'); - } - - const catPath = path.join(testsRoot, catName); - - const subDirs = fs.readdirSync(catPath, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name); - - subDirs.forEach((dirName) => { - if(!dirName.endsWith('Parts')) { - throw new Error(`Unexpected sub-directory in the test category: ${path.join(catPath, dirName)}`); - } - }); - - const suites = fs.readdirSync(catPath, { withFileTypes: true }) - .filter((entry) => entry.isFile() && entry.name.endsWith('.js')) - .map((entry) => suiteFromPath(catName, path.join(catPath, entry.name))) - .sort((a, b) => a.ShortName.localeCompare(b.ShortName)); - - return suites; - } - - function getAllSuites({ - deviceMode, - constellation, - includeCategories, - excludeCategories, - excludeSuites, - partIndex, - partCount, - }) { - const includeSpecified = includeCategories && includeCategories.size > 0; - const excludeSpecified = excludeCategories && excludeCategories.size > 0; - const result = []; - - readCategories().forEach((category) => { - if(deviceMode && !category.RunOnDevices) { - return; - } - - if(constellation && category.Constellation !== constellation) { - return; - } - - if(includeSpecified && !includeCategories.has(category.Name)) { - return; - } - - if(category.Explicit && (!includeSpecified || !includeCategories.has(category.Name))) { - return; - } - - if(excludeSpecified && excludeCategories.has(category.Name)) { - return; - } - - let index = 0; - readSuites(category.Name).forEach((suite) => { - if(partCount > 1 && (index % partCount) !== partIndex) { - index += 1; - return; - } - - index += 1; - - if(excludeSuites && excludeSuites.has(suite.FullName)) { - return; - } - - result.push(suite); - }); - }); - - return result; - } - - function buildRunSuiteModel(catName, suiteName) { - return { - Title: suiteName, - ScriptVirtualPath: getSuiteVirtualPath(catName, suiteName), - }; - } - - function getSuiteVirtualPath(catName, suiteName) { - return `/packages/devextreme/testing/tests/${catName}/${suiteName}`; - } - - function categoryFromPath(categoryPath) { - const name = path.basename(categoryPath); - const metaPath = path.join(categoryPath, '__meta.json'); - const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); - const constellation = String(meta.constellation || ''); - - if(!knownConstellations.has(constellation)) { - throw new Error(`Unknown constellation (group of categories):${constellation}`); - } - - return { - Name: name, - Constellation: constellation, - Explicit: Boolean(meta.explicit), - RunOnDevices: Boolean(meta.runOnDevices), - }; - } - - function suiteFromPath(catName, suitePath) { - const suiteName = path.basename(suitePath); - const shortName = path.basename(suitePath, '.js'); - - return { - ShortName: shortName, - FullName: `${catName}/${suiteName}`, - Url: `/run/${encodeURIComponent(catName)}/${encodeURIComponent(suiteName)}`, - }; - } - - return { - buildRunSuiteModel, - getAllSuites, - readCategories, - readSuites, - }; -} - -function isNotEmptyDir(dirPath) { - try { - return fs.readdirSync(dirPath).length > 0; - } catch(_) { - return false; - } -} - -module.exports = { - createSuitesService, -}; diff --git a/packages/devextreme/testing/runner/lib/suites.ts b/packages/devextreme/testing/runner/lib/suites.ts new file mode 100644 index 000000000000..14bd55f76f27 --- /dev/null +++ b/packages/devextreme/testing/runner/lib/suites.ts @@ -0,0 +1,196 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { CategoryInfo, RunSuiteModel, SuiteInfo } from './types'; + +export interface GetAllSuitesOptions { + deviceMode: boolean; + constellation: string; + includeCategories: Set | null; + excludeCategories: Set | null; + excludeSuites: Set | null; + partIndex: number; + partCount: number; +} + +export interface SuitesService { + buildRunSuiteModel: (catName: string, suiteName: string) => RunSuiteModel; + getAllSuites: (options: GetAllSuitesOptions) => SuiteInfo[]; + readCategories: () => CategoryInfo[]; + readSuites: (catName: string) => SuiteInfo[]; +} + +interface SuitesServiceOptions { + knownConstellations: Set; + testsRoot: string; +} + +export function createSuitesService({ + knownConstellations, + testsRoot, +}: SuitesServiceOptions): SuitesService { + function readCategories(): CategoryInfo[] { + const dirs = fs.readdirSync(testsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(testsRoot, entry.name)) + .filter(isNotEmptyDir) + .map(categoryFromPath) + .sort((a, b) => a.Name.localeCompare(b.Name)); + + return dirs; + } + + function readSuites(catName: string): SuiteInfo[] { + if (!catName) { + throw new Error('Category name is required.'); + } + + const catPath = path.join(testsRoot, catName); + + const subDirs = fs.readdirSync(catPath, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + + subDirs.forEach((dirName) => { + if (!dirName.endsWith('Parts')) { + throw new Error(`Unexpected sub-directory in the test category: ${path.join(catPath, dirName)}`); + } + }); + + const suites = fs.readdirSync(catPath, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.js')) + .map((entry) => suiteFromPath(catName, path.join(catPath, entry.name))) + .sort((a, b) => a.ShortName.localeCompare(b.ShortName)); + + return suites; + } + + function getAllSuites({ + deviceMode, + constellation, + includeCategories, + excludeCategories, + excludeSuites, + partIndex, + partCount, + }: GetAllSuitesOptions): SuiteInfo[] { + const includeSpecified = includeCategories && includeCategories.size > 0; + const excludeSpecified = excludeCategories && excludeCategories.size > 0; + const result: SuiteInfo[] = []; + + readCategories().forEach((category) => { + if (deviceMode && !category.RunOnDevices) { + return; + } + + if (constellation && category.Constellation !== constellation) { + return; + } + + if (includeSpecified && !includeCategories.has(category.Name)) { + return; + } + + if (category.Explicit && (!includeSpecified || !includeCategories.has(category.Name))) { + return; + } + + if (excludeSpecified && excludeCategories.has(category.Name)) { + return; + } + + let index = 0; + readSuites(category.Name).forEach((suite) => { + if (partCount > 1 && (index % partCount) !== partIndex) { + index += 1; + return; + } + + index += 1; + + if (excludeSuites?.has(suite.FullName)) { + return; + } + + result.push(suite); + }); + }); + + return result; + } + + function buildRunSuiteModel(catName: string, suiteName: string): RunSuiteModel { + return { + Title: suiteName, + ScriptVirtualPath: getSuiteVirtualPath(catName, suiteName), + }; + } + + function getSuiteVirtualPath(catName: string, suiteName: string): string { + return `/packages/devextreme/testing/tests/${catName}/${suiteName}`; + } + + function categoryFromPath(categoryPath: string): CategoryInfo { + const name = path.basename(categoryPath); + const metaPath = path.join(categoryPath, '__meta.json'); + const parsed = JSON.parse(fs.readFileSync(metaPath, 'utf8')) as unknown; + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Invalid test category metadata: ${metaPath}`); + } + + const meta = parsed as { + constellation?: unknown; + explicit?: unknown; + runOnDevices?: unknown; + }; + + const constellationValue = toPrimitiveString(meta.constellation); + + if (!knownConstellations.has(constellationValue)) { + throw new Error(`Unknown constellation (group of categories):${constellationValue}`); + } + + return { + Name: name, + Constellation: constellationValue, + Explicit: Boolean(meta.explicit), + RunOnDevices: Boolean(meta.runOnDevices), + }; + } + + function suiteFromPath(catName: string, suitePath: string): SuiteInfo { + const suiteName = path.basename(suitePath); + const shortName = path.basename(suitePath, '.js'); + + return { + ShortName: shortName, + FullName: `${catName}/${suiteName}`, + Url: `/run/${encodeURIComponent(catName)}/${encodeURIComponent(suiteName)}`, + }; + } + + return { + buildRunSuiteModel, + getAllSuites, + readCategories, + readSuites, + }; +} + +function isNotEmptyDir(dirPath: string): boolean { + try { + return fs.readdirSync(dirPath).length > 0; + } catch { + return false; + } +} + +function toPrimitiveString(value: unknown): string { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + + return ''; +} diff --git a/packages/devextreme/testing/runner/lib/templates.js b/packages/devextreme/testing/runner/lib/templates.js deleted file mode 100644 index a4d25453a766..000000000000 --- a/packages/devextreme/testing/runner/lib/templates.js +++ /dev/null @@ -1,55 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -function createTemplateRenderer(templatesRoot, escapeHtml) { - const templateCache = new Map(); - - function readTemplate(templateName) { - const key = String(templateName || ''); - - if(templateCache.has(key)) { - return templateCache.get(key); - } - - const filePath = path.resolve(templatesRoot, key); - const relativePath = path.relative(templatesRoot, filePath); - - if(relativePath.startsWith('..') || path.isAbsolute(relativePath)) { - throw new Error(`Invalid template path: ${key}`); - } - - const templateText = fs.readFileSync(filePath, 'utf8'); - templateCache.set(key, templateText); - - return templateText; - } - - function getTemplateValue(data, key, shouldEscape) { - const hasValue = Object.prototype.hasOwnProperty.call(data, key); - const value = hasValue ? data[key] : ''; - const valueAsString = value === null || value === undefined ? '' : String(value); - - if(shouldEscape) { - return escapeHtml(valueAsString); - } - - return valueAsString; - } - - function renderTemplate(templateName, vars) { - const template = readTemplate(templateName); - const data = vars || {}; - - return template - .replace(/\{\{\{([A-Za-z0-9_]+)\}\}\}/g, (_, key) => getTemplateValue(data, key, false)) - .replace(/\{\{([A-Za-z0-9_]+)\}\}/g, (_, key) => getTemplateValue(data, key, true)); - } - - return { - renderTemplate, - }; -} - -module.exports = { - createTemplateRenderer, -}; diff --git a/packages/devextreme/testing/runner/lib/templates.ts b/packages/devextreme/testing/runner/lib/templates.ts new file mode 100644 index 000000000000..c57a1d4145e9 --- /dev/null +++ b/packages/devextreme/testing/runner/lib/templates.ts @@ -0,0 +1,76 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { TemplateVars } from './types'; + +export interface TemplateRenderer { + renderTemplate: (templateName: string, vars?: TemplateVars) => string; +} + +function stringifyTemplateValue(value: unknown): string { + if (value === null || value === undefined) { + return ''; + } + + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + + if (typeof value === 'bigint') { + return value.toString(); + } + + return JSON.stringify(value); +} + +export function createTemplateRenderer( + templatesRoot: string, + escapeHtml: (value: unknown) => string, +): TemplateRenderer { + const templateCache = new Map(); + + function readTemplate(templateName: string): string { + const key = String(templateName || ''); + + if (templateCache.has(key)) { + return templateCache.get(key) as string; + } + + const filePath = path.resolve(templatesRoot, key); + const relativePath = path.relative(templatesRoot, filePath); + + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + throw new Error(`Invalid template path: ${key}`); + } + + const templateText = fs.readFileSync(filePath, 'utf8'); + templateCache.set(key, templateText); + + return templateText; + } + + function getTemplateValue(data: TemplateVars, key: string, shouldEscape: boolean): string { + const hasValue = Object.prototype.hasOwnProperty.call(data, key); + const value = hasValue ? data[key] : ''; + const valueAsString = stringifyTemplateValue(value); + + if (shouldEscape) { + return escapeHtml(valueAsString); + } + + return valueAsString; + } + + function renderTemplate(templateName: string, vars: TemplateVars = {}): string { + const template = readTemplate(templateName); + const data = vars; + + return template + .replace(/\{\{\{([A-Za-z0-9_]+)\}\}\}/g, (_, key: string) => getTemplateValue(data, key, false)) + .replace(/\{\{([A-Za-z0-9_]+)\}\}/g, (_, key: string) => getTemplateValue(data, key, true)); + } + + return { + renderTemplate, + }; +} diff --git a/packages/devextreme/testing/runner/lib/types.ts b/packages/devextreme/testing/runner/lib/types.ts new file mode 100644 index 000000000000..248ce42adbcb --- /dev/null +++ b/packages/devextreme/testing/runner/lib/types.ts @@ -0,0 +1,88 @@ +export type RunnerLogColor = 'red' | 'green' | 'yellow' | 'white'; + +export interface RunnerLogger { + write: (message?: string, color?: RunnerLogColor) => void; + writeLine: (message?: string, color?: RunnerLogColor) => void; + writeError: (message: string) => void; +} + +export interface BaseRunProps { + IsContinuousIntegration: boolean; + NoGlobals: boolean; + NoTimers: boolean; + NoTryCatch: boolean; + NoJQuery: boolean; + ShadowDom: boolean; + WorkerInWindow: boolean; + NoCsp: boolean; + MaxWorkers: number | null; +} + +export interface CategoryInfo { + Name: string; + Constellation: string; + Explicit: boolean; + RunOnDevices: boolean; +} + +export interface SuiteInfo { + ShortName: string; + FullName: string; + Url: string; +} + +export interface RunSuiteModel { + Title: string; + ScriptVirtualPath: string; +} + +export interface RunAllModel { + Constellation: string; + CategoriesList: string; + Version: string; + Suites: SuiteInfo[]; +} + +export interface TestCaseIssue { + message?: string; +} + +export interface TestCaseResult { + name?: string; + url?: string; + time?: number | string; + executed?: boolean; + failure?: TestCaseIssue; + reason?: TestCaseIssue; +} + +export interface TestSuiteResult { + name?: string; + time?: number | string; + pureTime?: number | string; + results?: TestResultItem[]; +} + +export type TestResultItem = TestSuiteResult | TestCaseResult; + +export interface TestResultsPayload { + name?: string; + total?: number | string; + failures?: number | string; + suites?: TestSuiteResult[]; +} + +export interface VectorMapDataItem { + name: string; + expected: string; +} + +export interface VectorMapOutputItem { + file: string; + variable: string | null; + content: unknown; +} + +export type PortsMap = Record; + +export type TemplateVars = Record; diff --git a/packages/devextreme/testing/runner/lib/utils.js b/packages/devextreme/testing/runner/lib/utils.js deleted file mode 100644 index 6c60b9cd565f..000000000000 --- a/packages/devextreme/testing/runner/lib/utils.js +++ /dev/null @@ -1,164 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -function jsonString(value) { - return JSON.stringify(value); -} - -function escapeHtml(value) { - return String(value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -function escapeXmlText(value) { - return String(value) - .replace(/&/g, '&') - .replace(//g, '>'); -} - -function escapeXmlAttr(value) { - return escapeXmlText(value) - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -function loadPorts(filePath) { - return JSON.parse(fs.readFileSync(filePath, 'utf8')); -} - -function safeReadFile(filePath) { - try { - return fs.readFileSync(filePath, 'utf8'); - } catch(_) { - return ''; - } -} - -function parseBoolean(value) { - return String(value).toLowerCase() === 'true'; -} - -function parseNumber(value) { - const number = Number(value); - return Number.isNaN(number) ? 0 : number; -} - -function splitCommaList(value) { - return value - .split(',') - .map((item) => item.trim()) - .filter(Boolean); -} - -function safeDecodeURIComponent(value) { - try { - return decodeURIComponent(value); - } catch(_) { - return value; - } -} - -function pad2(value) { - return String(value).padStart(2, '0'); -} - -function formatDateForSuiteTimestamp(date) { - return [ - date.getFullYear(), - pad2(date.getMonth() + 1), - pad2(date.getDate()), - ].join('-') + 'T' + [ - pad2(date.getHours()), - pad2(date.getMinutes()), - pad2(date.getSeconds()), - ].join(':'); -} - -function isContinuousIntegration() { - return Boolean(process.env.CCNetWorkingDirectory || process.env.DEVEXTREME_TEST_CI); -} - -function resolveNodePath() { - if(process.env.CCNetWorkingDirectory) { - const customPath = path.join(process.env.CCNetWorkingDirectory, 'node', 'node.exe'); - if(fs.existsSync(customPath)) { - return customPath; - } - } - - return 'node'; -} - -function readBodyText(req) { - return new Promise((resolve, reject) => { - const chunks = []; - - req.on('data', (chunk) => { - chunks.push(chunk); - }); - - req.on('end', () => { - resolve(Buffer.concat(chunks).toString('utf8')); - }); - - req.on('error', reject); - }); -} - -async function readFormBody(req) { - const body = await readBodyText(req); - return Object.fromEntries(new URLSearchParams(body)); -} - -function getCacheBuster(searchParams) { - if(searchParams.has('DX_HTTP_CACHE')) { - const value = searchParams.get('DX_HTTP_CACHE') || ''; - return `DX_HTTP_CACHE=${encodeURIComponent(value)}`; - } - - return ''; -} - -function contentWithCacheBuster(contentPath, cacheBuster) { - if(!cacheBuster) { - return contentPath; - } - - return `${contentPath}${contentPath.includes('?') ? '&' : '?'}${cacheBuster}`; -} - -function normalizeNumber(value) { - const number = Number(value); - if(Number.isNaN(number)) { - return 0; - } - - return number; -} - -module.exports = { - contentWithCacheBuster, - escapeHtml, - escapeXmlAttr, - escapeXmlText, - formatDateForSuiteTimestamp, - getCacheBuster, - isContinuousIntegration, - jsonString, - loadPorts, - normalizeNumber, - pad2, - parseBoolean, - parseNumber, - readBodyText, - readFormBody, - resolveNodePath, - safeDecodeURIComponent, - safeReadFile, - splitCommaList, -}; diff --git a/packages/devextreme/testing/runner/lib/utils.ts b/packages/devextreme/testing/runner/lib/utils.ts new file mode 100644 index 000000000000..a1bb0d1beb51 --- /dev/null +++ b/packages/devextreme/testing/runner/lib/utils.ts @@ -0,0 +1,151 @@ +import * as fs from 'node:fs'; +import { IncomingMessage } from 'node:http'; +import * as path from 'node:path'; + +import { PortsMap } from './types'; + +export function jsonString(value: unknown): string { + return JSON.stringify(value); +} + +export function escapeHtml(value: unknown): string { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function escapeXmlText(value: unknown): string { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>'); +} + +export function escapeXmlAttr(value: unknown): string { + return escapeXmlText(value) + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function loadPorts(filePath: string): PortsMap { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Invalid ports definition: ${filePath}`); + } + + return parsed as PortsMap; +} + +export function safeReadFile(filePath: string): string { + try { + return fs.readFileSync(filePath, 'utf8'); + } catch { + return ''; + } +} + +export function parseBoolean(value: unknown): boolean { + return String(value).toLowerCase() === 'true'; +} + +export function parseNumber(value: unknown): number { + const number = Number(value); + return Number.isNaN(number) ? 0 : number; +} + +export function splitCommaList(value: string): string[] { + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +export function safeDecodeURIComponent(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +export function pad2(value: number): string { + return String(value).padStart(2, '0'); +} + +export function formatDateForSuiteTimestamp(date: Date): string { + return `${[ + date.getFullYear(), + pad2(date.getMonth() + 1), + pad2(date.getDate()), + ].join('-')}T${[ + pad2(date.getHours()), + pad2(date.getMinutes()), + pad2(date.getSeconds()), + ].join(':')}`; +} + +export function isContinuousIntegration(): boolean { + return Boolean(process.env.CCNetWorkingDirectory ?? process.env.DEVEXTREME_TEST_CI); +} + +export function resolveNodePath(): string { + if (process.env.CCNetWorkingDirectory) { + const customPath = path.join(process.env.CCNetWorkingDirectory, 'node', 'node.exe'); + if (fs.existsSync(customPath)) { + return customPath; + } + } + + return 'node'; +} + +export function readBodyText(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + + req.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + + req.on('end', () => { + resolve(Buffer.concat(chunks).toString('utf8')); + }); + + req.on('error', reject); + }); +} + +export async function readFormBody(req: IncomingMessage): Promise> { + const body = await readBodyText(req); + return Object.fromEntries(new URLSearchParams(body)); +} + +export function getCacheBuster(searchParams: URLSearchParams): string { + if (searchParams.has('DX_HTTP_CACHE')) { + const value = searchParams.get('DX_HTTP_CACHE') ?? ''; + return `DX_HTTP_CACHE=${encodeURIComponent(value)}`; + } + + return ''; +} + +export function contentWithCacheBuster(contentPath: string, cacheBuster: string): string { + if (!cacheBuster) { + return contentPath; + } + + return `${contentPath}${contentPath.includes('?') ? '&' : '?'}${cacheBuster}`; +} + +export function normalizeNumber(value: unknown): number { + const number = Number(value); + if (Number.isNaN(number)) { + return 0; + } + + return number; +} diff --git a/packages/devextreme/testing/runner/lib/vectormap.js b/packages/devextreme/testing/runner/lib/vectormap.js deleted file mode 100644 index f9d0c5eae7e2..000000000000 --- a/packages/devextreme/testing/runner/lib/vectormap.js +++ /dev/null @@ -1,234 +0,0 @@ -const fs = require('fs'); -const http = require('http'); -const path = require('path'); -const { spawn, spawnSync } = require('child_process'); - -function createVectorMapService({ - packageRoot, - testingRoot, - vectorDataDirectory, - vectorMapTesterPort, - pathToNode, -}) { - const vectorMapNodeServer = { - process: null, - refs: 0, - killTimer: null, - }; - - function readThemeCssFiles() { - const bundlesPath = path.join(packageRoot, 'scss', 'bundles'); - const result = []; - - if(!fs.existsSync(bundlesPath)) { - return result; - } - - fs.readdirSync(bundlesPath, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .forEach((entry) => { - const bundleDirectory = path.join(bundlesPath, entry.name); - fs.readdirSync(bundleDirectory, { withFileTypes: true }) - .filter((file) => file.isFile() && file.name.endsWith('.scss')) - .forEach((file) => { - result.push(`${path.basename(file.name, '.scss')}.css`); - }); - }); - - return result; - } - - function readVectorMapTestData() { - if(!fs.existsSync(vectorDataDirectory)) { - return []; - } - - return fs.readdirSync(vectorDataDirectory, { withFileTypes: true }) - .filter((entry) => entry.isFile() && entry.name.endsWith('.txt')) - .map((entry) => { - const filePath = path.join(vectorDataDirectory, entry.name); - return { - name: path.basename(entry.name, '.txt'), - expected: fs.readFileSync(filePath, 'utf8'), - }; - }); - } - - async function redirectRequestToVectorMapNodeServer(action, arg) { - acquireVectorMapNodeServer(); - - try { - const startTime = Date.now(); - - while(true) { - try { - const text = await httpGetText(`http://127.0.0.1:${vectorMapTesterPort}/${action}/${arg}`); - return text; - } catch(error) { - if(Date.now() - startTime > 5000) { - throw error; - } - - await wait(50); - } - } - } finally { - releaseVectorMapNodeServer(); - } - } - - function executeVectorMapConsoleApp(arg, searchParams) { - const inputDirectory = `${path.join(packageRoot, 'testing', 'content', 'VectorMapData')}${path.sep}`; - const outputDirectory = path.join(inputDirectory, '__Output'); - const settingsPath = path.join(inputDirectory, '_settings.js'); - const processFileContentPath = path.join(inputDirectory, '_processFileContent.js'); - const vectorMapUtilsNodePath = path.resolve(path.join(packageRoot, 'artifacts/js/vectormap-utils/dx.vectormaputils.node.js')); - - const args = [vectorMapUtilsNodePath, inputDirectory]; - - if(searchParams.has('file')) { - args[1] += searchParams.get('file'); - } - - args.push('--quiet', '--output', outputDirectory, '--settings', settingsPath, '--process-file-content', processFileContentPath); - - const isJson = searchParams.has('json'); - - if(isJson) { - args.push('--json'); - } - - fs.mkdirSync(outputDirectory, { recursive: true }); - - try { - const spawnResult = spawnSync(pathToNode, args, { - timeout: 15000, - stdio: 'ignore', - }); - - if(spawnResult.error) { - if(spawnResult.error.code === 'ETIMEDOUT') { - // Intentionally ignored to match legacy behavior. - } else { - throw spawnResult.error; - } - } - - const extension = isJson ? '.json' : '.js'; - - return fs.readdirSync(outputDirectory, { withFileTypes: true }) - .filter((entry) => entry.isFile() && entry.name.endsWith(extension)) - .map((entry) => { - const filePath = path.join(outputDirectory, entry.name); - let text = fs.readFileSync(filePath, 'utf8'); - let variable = null; - - if(!isJson) { - const index = text.indexOf('='); - if(index > 0) { - variable = text.substring(0, index).trim(); - text = text.substring(index + 1).trim(); - - if(text.endsWith(';')) { - text = text.slice(0, -1).trim(); - } - } - } - - return { - file: `${path.basename(entry.name, extension)}${extension}`, - variable, - content: JSON.parse(text), - }; - }); - } finally { - try { - fs.rmSync(outputDirectory, { recursive: true, force: true }); - } catch(_) { - // Ignore cleanup errors. - } - } - } - - function acquireVectorMapNodeServer() { - if(vectorMapNodeServer.killTimer) { - clearTimeout(vectorMapNodeServer.killTimer); - vectorMapNodeServer.killTimer = null; - } - - if(!vectorMapNodeServer.process || vectorMapNodeServer.process.killed) { - const scriptPath = path.join(testingRoot, 'helpers', 'vectormaputils-tester.js'); - - vectorMapNodeServer.process = spawn( - pathToNode, - [scriptPath, `${vectorDataDirectory}${path.sep}`], - { - stdio: 'ignore', - }, - ); - - vectorMapNodeServer.process.on('exit', () => { - if(vectorMapNodeServer.process && vectorMapNodeServer.process.exitCode !== null) { - vectorMapNodeServer.process = null; - } - }); - } - - vectorMapNodeServer.refs += 1; - } - - function releaseVectorMapNodeServer() { - vectorMapNodeServer.refs -= 1; - - if(vectorMapNodeServer.refs <= 0) { - vectorMapNodeServer.refs = 0; - - vectorMapNodeServer.killTimer = setTimeout(() => { - if(vectorMapNodeServer.refs === 0 && vectorMapNodeServer.process) { - try { - vectorMapNodeServer.process.kill(); - } catch(_) { - // Ignore process kill failures. - } - vectorMapNodeServer.process = null; - } - vectorMapNodeServer.killTimer = null; - }, 200); - } - } - - return { - executeVectorMapConsoleApp, - readThemeCssFiles, - readVectorMapTestData, - redirectRequestToVectorMapNodeServer, - }; -} - -function httpGetText(targetUrl) { - return new Promise((resolve, reject) => { - const request = http.get(targetUrl, (response) => { - const chunks = []; - - response.on('data', (chunk) => { - chunks.push(chunk); - }); - - response.on('end', () => { - resolve(Buffer.concat(chunks).toString('utf8')); - }); - }); - - request.on('error', reject); - }); -} - -function wait(timeout) { - return new Promise((resolve) => { - setTimeout(resolve, timeout); - }); -} - -module.exports = { - createVectorMapService, -}; diff --git a/packages/devextreme/testing/runner/lib/vectormap.ts b/packages/devextreme/testing/runner/lib/vectormap.ts new file mode 100644 index 000000000000..5ca8fd3064e8 --- /dev/null +++ b/packages/devextreme/testing/runner/lib/vectormap.ts @@ -0,0 +1,263 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +/* eslint-disable no-await-in-loop */ +import { ChildProcess, spawn, spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as path from 'node:path'; + +import { VectorMapDataItem, VectorMapOutputItem } from './types'; + +interface VectorMapServiceOptions { + packageRoot: string; + testingRoot: string; + vectorDataDirectory: string; + vectorMapTesterPort: number; + pathToNode: string; +} + +export interface VectorMapService { + executeVectorMapConsoleApp: (arg: string, searchParams: URLSearchParams) => VectorMapOutputItem[]; + readThemeCssFiles: () => string[]; + readVectorMapTestData: () => VectorMapDataItem[]; + redirectRequestToVectorMapNodeServer: (action: string, arg: string) => Promise; +} + +interface VectorMapNodeServerState { + process: ChildProcess | null; + refs: number; + killTimer: NodeJS.Timeout | null; +} + +export function createVectorMapService({ + packageRoot, + testingRoot, + vectorDataDirectory, + vectorMapTesterPort, + pathToNode, +}: VectorMapServiceOptions): VectorMapService { + const vectorMapNodeServer: VectorMapNodeServerState = { + process: null, + refs: 0, + killTimer: null, + }; + + function readThemeCssFiles(): string[] { + const bundlesPath = path.join(packageRoot, 'scss', 'bundles'); + const result: string[] = []; + + if (!fs.existsSync(bundlesPath)) { + return result; + } + + fs.readdirSync(bundlesPath, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .forEach((entry) => { + const bundleDirectory = path.join(bundlesPath, entry.name); + fs.readdirSync(bundleDirectory, { withFileTypes: true }) + .filter((file) => file.isFile() && file.name.endsWith('.scss')) + .forEach((file) => { + result.push(`${path.basename(file.name, '.scss')}.css`); + }); + }); + + return result; + } + + function readVectorMapTestData(): VectorMapDataItem[] { + if (!fs.existsSync(vectorDataDirectory)) { + return []; + } + + return fs.readdirSync(vectorDataDirectory, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.txt')) + .map((entry) => { + const filePath = path.join(vectorDataDirectory, entry.name); + return { + name: path.basename(entry.name, '.txt'), + expected: fs.readFileSync(filePath, 'utf8'), + }; + }); + } + + async function redirectRequestToVectorMapNodeServer( + action: string, + arg: string, + ): Promise { + acquireVectorMapNodeServer(); + + try { + const startTime = Date.now(); + + while (true) { + try { + const text = await httpGetText(`http://127.0.0.1:${vectorMapTesterPort}/${action}/${arg}`); + return text; + } catch(error) { + if (Date.now() - startTime > 5000) { + throw error; + } + + await wait(50); + } + } + } finally { + releaseVectorMapNodeServer(); + } + } + + function executeVectorMapConsoleApp( + arg: string, + searchParams: URLSearchParams, + ): VectorMapOutputItem[] { + const inputDirectory = `${path.join(packageRoot, 'testing', 'content', 'VectorMapData')}${path.sep}`; + const outputDirectory = path.join(inputDirectory, '__Output'); + const settingsPath = path.join(inputDirectory, '_settings.js'); + const processFileContentPath = path.join(inputDirectory, '_processFileContent.js'); + const vectorMapUtilsNodePath = path.resolve(path.join(packageRoot, 'artifacts/js/vectormap-utils/dx.vectormaputils.node.js')); + + const args = [vectorMapUtilsNodePath, inputDirectory]; + + if (searchParams.has('file')) { + args[1] += searchParams.get('file'); + } + + args.push('--quiet', '--output', outputDirectory, '--settings', settingsPath, '--process-file-content', processFileContentPath); + + const isJson = searchParams.has('json'); + + if (isJson) { + args.push('--json'); + } + + fs.mkdirSync(outputDirectory, { recursive: true }); + + try { + const spawnResult = spawnSync(pathToNode, args, { + timeout: 15000, + stdio: 'ignore', + }); + + const spawnError = spawnResult.error as (Error & { code?: string }) | undefined; + + if (spawnError) { + if (spawnError.code === 'ETIMEDOUT') { + // Intentionally ignored to match legacy behavior. + } else { + throw spawnError; + } + } + + const extension = isJson ? '.json' : '.js'; + + return fs.readdirSync(outputDirectory, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(extension)) + .map((entry) => { + const filePath = path.join(outputDirectory, entry.name); + let text = fs.readFileSync(filePath, 'utf8'); + let variable: string | null = null; + + if (!isJson) { + const index = text.indexOf('='); + if (index > 0) { + variable = text.substring(0, index).trim(); + text = text.substring(index + 1).trim(); + + if (text.endsWith(';')) { + text = text.slice(0, -1).trim(); + } + } + } + + return { + file: `${path.basename(entry.name, extension)}${extension}`, + variable, + content: JSON.parse(text), + }; + }); + } finally { + try { + fs.rmSync(outputDirectory, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors. + } + } + } + + function acquireVectorMapNodeServer(): void { + if (vectorMapNodeServer.killTimer) { + clearTimeout(vectorMapNodeServer.killTimer); + vectorMapNodeServer.killTimer = null; + } + + if (!vectorMapNodeServer.process || vectorMapNodeServer.process.killed) { + const scriptPath = path.join(testingRoot, 'helpers', 'vectormaputils-tester.js'); + + vectorMapNodeServer.process = spawn( + pathToNode, + [scriptPath, `${vectorDataDirectory}${path.sep}`], + { + stdio: 'ignore', + }, + ); + + vectorMapNodeServer.process.on('exit', () => { + if (vectorMapNodeServer.process && vectorMapNodeServer.process.exitCode !== null) { + vectorMapNodeServer.process = null; + } + }); + } + + vectorMapNodeServer.refs += 1; + } + + function releaseVectorMapNodeServer(): void { + vectorMapNodeServer.refs -= 1; + + if (vectorMapNodeServer.refs <= 0) { + vectorMapNodeServer.refs = 0; + + vectorMapNodeServer.killTimer = setTimeout(() => { + if (vectorMapNodeServer.refs === 0 && vectorMapNodeServer.process) { + try { + vectorMapNodeServer.process.kill(); + } catch { + // Ignore process kill failures. + } + vectorMapNodeServer.process = null; + } + vectorMapNodeServer.killTimer = null; + }, 200); + } + } + + return { + executeVectorMapConsoleApp, + readThemeCssFiles, + readVectorMapTestData, + redirectRequestToVectorMapNodeServer, + }; +} + +function httpGetText(targetUrl: string): Promise { + return new Promise((resolve, reject) => { + const request = http.get(targetUrl, (response) => { + const chunks: Buffer[] = []; + + response.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + + response.on('end', () => { + resolve(Buffer.concat(chunks).toString('utf8')); + }); + }); + + request.on('error', reject); + }); +} + +function wait(timeout: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, timeout); + }); +} diff --git a/packages/devextreme/testing/runner/tsconfig.json b/packages/devextreme/testing/runner/tsconfig.json new file mode 100644 index 000000000000..6cfe9b9ac0ae --- /dev/null +++ b/packages/devextreme/testing/runner/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": [ + "node" + ], + "rootDir": ".", + "outDir": "dist" + }, + "include": [ + "index.ts", + "lib/**/*.ts" + ], + "exclude": [ + "dist", + "templates" + ] +} diff --git a/packages/devextreme/tsconfig.json b/packages/devextreme/tsconfig.json index e7a42b1c5f6c..37ee33f2a594 100644 --- a/packages/devextreme/tsconfig.json +++ b/packages/devextreme/tsconfig.json @@ -47,6 +47,7 @@ "include": [ "js/**/*.d.ts", "../devextreme-themebuilder", - "testing/testcafe" + "testing/testcafe", + "testing/runner/**/*.ts" ] } From 8e42f387ccf6a6250d41accc38b9be09694f80d1 Mon Sep 17 00:00:00 2001 From: Eugen Zha Date: Thu, 5 Mar 2026 13:43:39 +0200 Subject: [PATCH 10/11] Improve types --- packages/devextreme/testing/runner/index.ts | 49 ++-- .../devextreme/testing/runner/lib/logger.ts | 94 ++++--- .../devextreme/testing/runner/lib/results.ts | 246 ++++++++++++----- .../devextreme/testing/runner/lib/static.ts | 242 ++++++++--------- .../devextreme/testing/runner/lib/suites.ts | 138 ++++++---- .../testing/runner/lib/templates.ts | 13 +- .../devextreme/testing/runner/lib/types.ts | 68 +++-- .../devextreme/testing/runner/lib/utils.ts | 29 +- .../testing/runner/lib/vectormap.ts | 250 +++++++++++------- 9 files changed, 694 insertions(+), 435 deletions(-) diff --git a/packages/devextreme/testing/runner/index.ts b/packages/devextreme/testing/runner/index.ts index 7841f06271b2..547f3985cfd3 100644 --- a/packages/devextreme/testing/runner/index.ts +++ b/packages/devextreme/testing/runner/index.ts @@ -43,9 +43,16 @@ import { setStaticCacheHeaders, } from './lib/http'; import { createStaticFileService } from './lib/static'; -import { BaseRunProps, RunAllModel, TestResultsPayload } from './lib/types'; +import { + BaseRunProps, + ConstellationFilter, + ConstellationName, + KNOWN_CONSTELLATION_NAMES, + RunAllModel, + TestResultsPayload, +} from './lib/types'; -const KNOWN_CONSTELLATIONS = new Set(['export', 'misc', 'ui', 'ui.widgets', 'ui.editors', 'ui.grid', 'ui.scheduler']); +const KNOWN_CONSTELLATIONS = new Set(KNOWN_CONSTELLATION_NAMES); const RUNNER_ROOT = fs.existsSync(path.join(__dirname, 'templates')) ? __dirname @@ -349,7 +356,7 @@ function buildRunAllModel(searchParams: URLSearchParams): RunAllModel { let partIndex = 0; let partCount = 1; - let constellation = searchParams.get('constellation'); + let constellation: ConstellationFilter = searchParams.get('constellation') ?? ''; const include = searchParams.get('include'); const exclude = searchParams.get('exclude'); @@ -361,7 +368,7 @@ function buildRunAllModel(searchParams: URLSearchParams): RunAllModel { excludeSet = new Set(splitCommaList(exclude)); } - if (constellation && constellation.includes('(') && constellation.endsWith(')')) { + if (constellation.includes('(') && constellation.endsWith(')')) { const [name, partInfo] = constellation.slice(0, -1).split('('); const parts = partInfo.split('/'); @@ -379,17 +386,13 @@ function buildRunAllModel(searchParams: URLSearchParams): RunAllModel { excludeSuites = new Set(completedSuites); } - const packageJson = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')) as { - version?: unknown; - }; - return { - Constellation: constellation ?? '', + Constellation: constellation, CategoriesList: include ?? '', - Version: stringifyPrimitive(packageJson.version), + Version: readPackageVersion(), Suites: suitesService.getAllSuites({ deviceMode: hasDeviceModeFlag(searchParams), - constellation: constellation ?? '', + constellation, includeCategories: includeSet, excludeCategories: excludeSet, excludeSuites, @@ -400,6 +403,8 @@ function buildRunAllModel(searchParams: URLSearchParams): RunAllModel { } function assignBaseRunProps(searchParams: URLSearchParams): BaseRunProps { + const maxWorkersRaw = process.env.MAX_WORKERS; + const result: BaseRunProps = { IsContinuousIntegration: RUN_FLAGS.isContinuousIntegration, NoGlobals: searchParams.has('noglobals'), @@ -412,8 +417,8 @@ function assignBaseRunProps(searchParams: URLSearchParams): BaseRunProps { MaxWorkers: null, }; - if (process.env.MAX_WORKERS && /^\d+$/.test(process.env.MAX_WORKERS)) { - result.MaxWorkers = Number(process.env.MAX_WORKERS); + if (typeof maxWorkersRaw === 'string' && /^\d+$/.test(maxWorkersRaw)) { + result.MaxWorkers = Number(maxWorkersRaw); } return result; @@ -431,8 +436,8 @@ async function saveResults(req: http.IncomingMessage, res: http.ServerResponse): const json = await readBodyText(req); resultsReporter.validateResultsJson(json); - const parsedResults = JSON.parse(json) as TestResultsPayload; - hasFailure = Number(parsedResults.failures) > 0; + const parsedResults: TestResultsPayload = resultsReporter.parseResultsJson(json); + hasFailure = parsedResults.failures > 0; xml = resultsReporter.testResultsToXml(parsedResults); if (RUN_FLAGS.singleRun) { @@ -479,3 +484,17 @@ function stringifyPrimitive(value: unknown): string { return ''; } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readPackageVersion(): string { + const parsed = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')) as unknown; + + if (isRecord(parsed)) { + return stringifyPrimitive(parsed.version); + } + + return ''; +} diff --git a/packages/devextreme/testing/runner/lib/logger.ts b/packages/devextreme/testing/runner/lib/logger.ts index 470e6b669171..79460e4aeb12 100644 --- a/packages/devextreme/testing/runner/lib/logger.ts +++ b/packages/devextreme/testing/runner/lib/logger.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-use-before-define */ import * as fs from 'node:fs'; import { RunnerLogColor, RunnerLogger } from './types'; @@ -10,26 +9,33 @@ interface RawLogger { write: (text?: string) => void; } -export function createRunnerLogger(filePath: string): RunnerLogger { - const rawLogger = createRawLogger(filePath); +const COLOR_CODES: Readonly> = { + red: 31, + green: 32, + yellow: 33, + white: 37, +}; - return { - write(message, color): void { - const text = String(message || ''); - rawLogger.write(text); - process.stdout.write(colorize(text, color)); - }, - writeLine(message, color): void { - const text = String(message || ''); - rawLogger.writeLine(text); - process.stdout.write(`${colorize(text, color)}\n`); - }, - writeError(message: string): void { - const text = `ERROR: ${message}`; - rawLogger.writeLine(text); - process.stderr.write(`${text}\n`); - }, - }; +function pad2(value: number): string { + return String(value).padStart(2, '0'); +} + +function formatLogTime(date: Date): string { + let hours = date.getHours() % 12; + if (hours === 0) { + hours = 12; + } + + return `${pad2(hours)}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`; +} + +function colorize(text: string, color?: RunnerLogColor): string { + if (color === undefined) { + return text; + } + + const code = COLOR_CODES[color]; + return `\u001b[${code}m${text}\u001b[0m`; } function createRawLogger(filePath: string): RawLogger { @@ -37,11 +43,11 @@ function createRawLogger(filePath: string): RawLogger { filePath, shouldWriteTimePrefix: true, writeLine(text = '') { - this.write(`${text || ''}\r\n`); + this.write(`${text}\r\n`); this.shouldWriteTimePrefix = true; }, write(text = '') { - if (!text) { + if (text.length === 0) { return; } @@ -57,32 +63,24 @@ function createRawLogger(filePath: string): RawLogger { return logger; } -function formatLogTime(date: Date): string { - let hours = date.getHours() % 12; - if (hours === 0) { - hours = 12; - } - - return `${pad2(hours)}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`; -} - -function pad2(value: number): string { - return String(value).padStart(2, '0'); -} - -function colorize(text: string, color?: RunnerLogColor): string { - if (!color) { - return text; - } +export function createRunnerLogger(filePath: string): RunnerLogger { + const rawLogger = createRawLogger(filePath); - const colorCodes: Record = { - red: 31, - green: 32, - yellow: 33, - white: 37, + return { + write(message, color): void { + const text = String(message ?? ''); + rawLogger.write(text); + process.stdout.write(colorize(text, color)); + }, + writeLine(message, color): void { + const text = String(message ?? ''); + rawLogger.writeLine(text); + process.stdout.write(`${colorize(text, color)}\n`); + }, + writeError(message: string): void { + const text = `ERROR: ${message}`; + rawLogger.writeLine(text); + process.stderr.write(`${text}\n`); + }, }; - - const code = colorCodes[color]; - - return `\u001b[${code}m${text}\u001b[0m`; } diff --git a/packages/devextreme/testing/runner/lib/results.ts b/packages/devextreme/testing/runner/lib/results.ts index 832448751fc4..ad56e1756d64 100644 --- a/packages/devextreme/testing/runner/lib/results.ts +++ b/packages/devextreme/testing/runner/lib/results.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ import { RunnerLogColor, + TestCaseIssue, TestCaseResult, TestResultItem, TestResultsPayload, @@ -8,12 +9,13 @@ import { } from './types'; interface ResultsReporterDeps { - escapeXmlAttr: (value: unknown) => string; - escapeXmlText: (value: unknown) => string; + escapeXmlAttr: (value: string | number | boolean | null) => string; + escapeXmlText: (value: string | number | boolean | null) => string; normalizeNumber: (value: unknown) => number; } export interface ResultsReporter { + parseResultsJson: (json: string) => TestResultsPayload; printTextReport: ( results: TestResultsPayload, writeLine: (message?: string, color?: RunnerLogColor) => void, @@ -22,6 +24,100 @@ export interface ResultsReporter { validateResultsJson: (json: string) => void; } +interface UnknownRecord { + [key: string]: unknown; +} + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function toStringValue(value: unknown): string { + if (typeof value === 'string') { + return value; + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + + return ''; +} + +function isSuiteResultItem(item: TestResultItem): item is TestSuiteResult { + return 'results' in item; +} + +function enumerateAllCases( + suite: TestSuiteResult, + callback: (testCase: TestCaseResult) => void, +): void { + suite.results.forEach((item) => { + if (isSuiteResultItem(item)) { + enumerateAllCases(item, callback); + return; + } + + callback(item); + }); +} + +function normalizeIssue(value: unknown): TestCaseIssue | null { + if (!isRecord(value)) { + return null; + } + + if (typeof value.message !== 'string') { + return null; + } + + return { + message: value.message, + }; +} + +function normalizeCase( + value: unknown, + normalizeNumber: (value: unknown) => number, +): TestCaseResult { + const record = isRecord(value) ? value : {}; + + return { + name: toStringValue(record.name), + url: toStringValue(record.url), + time: normalizeNumber(record.time), + executed: record.executed !== false, + failure: normalizeIssue(record.failure), + reason: normalizeIssue(record.reason), + }; +} + +function normalizeResultItem( + value: unknown, + normalizeNumber: (value: unknown) => number, +): TestResultItem { + if (isRecord(value) && Array.isArray(value.results)) { + return normalizeSuite(value, normalizeNumber); + } + + return normalizeCase(value, normalizeNumber); +} + +function normalizeSuite( + value: unknown, + normalizeNumber: (value: unknown) => number, +): TestSuiteResult { + const record = isRecord(value) ? value : {}; + const rawResults = Array.isArray(record.results) ? record.results : []; + + return { + name: toStringValue(record.name), + time: normalizeNumber(record.time), + pureTime: normalizeNumber(record.pureTime), + results: rawResults.map((item) => normalizeResultItem(item, normalizeNumber)), + }; +} + export function createResultsReporter({ escapeXmlAttr, escapeXmlText, @@ -38,6 +134,23 @@ export function createResultsReporter({ } } + function parseResultsJson(json: string): TestResultsPayload { + const parsed = JSON.parse(json) as unknown; + + if (!isRecord(parsed)) { + throw new Error('Invalid results payload: expected JSON object.'); + } + + const rawSuites = Array.isArray(parsed.suites) ? parsed.suites : []; + + return { + name: toStringValue(parsed.name), + total: normalizeNumber(parsed.total), + failures: normalizeNumber(parsed.failures), + suites: rawSuites.map((item) => normalizeSuite(item, normalizeNumber)), + }; + } + function printTextReport( results: TestResultsPayload, writeLine: (message?: string, color?: RunnerLogColor) => void, @@ -46,19 +159,18 @@ export function createResultsReporter({ const notRunCases: TestCaseResult[] = []; const failedCases: TestCaseResult[] = []; - (results.suites || []).forEach((suite) => { + results.suites.forEach((suite) => { enumerateAllCases(suite, (testCase) => { - if (testCase.reason) { + if (testCase.reason !== null) { notRunCases.push(testCase); } - if (testCase.failure) { + if (testCase.failure !== null) { failedCases.push(testCase); } }); }); - const total = Number(results.total) || 0; - const failures = Number(results.failures) || 0; + const { total, failures } = results; const notRunCount = notRunCases.length; let color: RunnerLogColor = 'green'; if (failures > 0) { @@ -72,8 +184,8 @@ export function createResultsReporter({ if (notRunCount > 0 && failures === 0) { notRunCases.forEach((testCase) => { writeLine('-'.repeat(80)); - writeLine(`Skipped: ${testCase.name || ''}`); - writeLine(`Reason: ${testCase.reason?.message || ''}`); + writeLine(`Skipped: ${testCase.name}`); + writeLine(`Reason: ${testCase.reason?.message ?? ''}`); }); } @@ -86,9 +198,9 @@ export function createResultsReporter({ } writeLine('-'.repeat(80)); - writeLine(testCase.name || '', 'white'); + writeLine(testCase.name, 'white'); writeLine(); - writeLine(testCase.failure?.message || ''); + writeLine(testCase.failure?.message ?? ''); writtenFailures += 1; }); @@ -99,53 +211,20 @@ export function createResultsReporter({ } } - function testResultsToXml(results: TestResultsPayload): string { - const lines: string[] = []; - - lines.push(``); - - (results.suites || []).forEach((suite) => { - lines.push(renderSuiteXml(suite, ' ')); - }); - - lines.push(''); - - return `${lines.join('\n')}\n`; - } - - function renderSuiteXml(suite: TestSuiteResult, indent: string): string { - const lines: string[] = []; - - lines.push(`${indent}`); - lines.push(`${indent} `); - - (suite.results || []).forEach((item) => { - if (isSuiteResultItem(item)) { - lines.push(renderSuiteXml(item, `${indent} `)); - } else { - lines.push(renderCaseXml(item || {}, `${indent} `)); - } - }); - - lines.push(`${indent} `); - lines.push(`${indent}`); - - return lines.join('\n'); - } - function renderCaseXml(testCase: TestCaseResult, indent: string): string { + const timeValue = testCase.time === 0 ? '' : testCase.time; const attributes = [ - `name="${escapeXmlAttr(testCase.name || '')}"`, - `url="${escapeXmlAttr(testCase.url || '')}"`, - `time="${escapeXmlAttr(testCase.time || '')}"`, + `name="${escapeXmlAttr(testCase.name)}"`, + `url="${escapeXmlAttr(testCase.url)}"`, + `time="${escapeXmlAttr(timeValue)}"`, ]; - if (testCase.executed === false) { + if (!testCase.executed) { attributes.push('executed="false"'); } - const hasFailure = typeof testCase.failure?.message === 'string'; - const hasReason = typeof testCase.reason?.message === 'string'; + const hasFailure = testCase.failure !== null; + const hasReason = testCase.reason !== null; if (!hasFailure && !hasReason) { return `${indent}`; @@ -155,13 +234,17 @@ export function createResultsReporter({ if (hasFailure) { lines.push(`${indent} `); - lines.push(`${indent} ${escapeXmlText(testCase.failure?.message || '')}`); + lines.push( + `${indent} ${escapeXmlText(testCase.failure?.message ?? '')}`, + ); lines.push(`${indent} `); } if (hasReason) { lines.push(`${indent} `); - lines.push(`${indent} ${escapeXmlText(testCase.reason?.message || '')}`); + lines.push( + `${indent} ${escapeXmlText(testCase.reason?.message ?? '')}`, + ); lines.push(`${indent} `); } @@ -170,27 +253,48 @@ export function createResultsReporter({ return lines.join('\n'); } + function renderSuiteXml(suite: TestSuiteResult, indent: string): string { + const lines: string[] = []; + + lines.push( + `${indent}`, + ); + lines.push(`${indent} `); + + suite.results.forEach((item) => { + if (isSuiteResultItem(item)) { + lines.push(renderSuiteXml(item, `${indent} `)); + } else { + lines.push(renderCaseXml(item, `${indent} `)); + } + }); + + lines.push(`${indent} `); + lines.push(`${indent}`); + + return lines.join('\n'); + } + + function testResultsToXml(results: TestResultsPayload): string { + const lines: string[] = []; + + lines.push( + ``, + ); + + results.suites.forEach((suite) => { + lines.push(renderSuiteXml(suite, ' ')); + }); + + lines.push(''); + + return `${lines.join('\n')}\n`; + } + return { + parseResultsJson, printTextReport, testResultsToXml, validateResultsJson, }; } - -function enumerateAllCases( - suite: TestSuiteResult, - callback: (testCase: TestCaseResult) => void, -): void { - (suite.results || []).forEach((item) => { - if (isSuiteResultItem(item)) { - enumerateAllCases(item, callback); - return; - } - - callback(item || {}); - }); -} - -function isSuiteResultItem(item: TestResultItem): item is TestSuiteResult { - return Array.isArray((item as TestSuiteResult).results); -} diff --git a/packages/devextreme/testing/runner/lib/static.ts b/packages/devextreme/testing/runner/lib/static.ts index 8bbd5534be1f..700f513bc99c 100644 --- a/packages/devextreme/testing/runner/lib/static.ts +++ b/packages/devextreme/testing/runner/lib/static.ts @@ -1,10 +1,9 @@ -/* eslint-disable @typescript-eslint/no-use-before-define */ import * as fs from 'node:fs'; import { IncomingMessage, ServerResponse } from 'node:http'; import * as path from 'node:path'; interface StaticFileServiceDeps { - escapeHtml: (value: unknown) => string; + escapeHtml: (value: string) => string; rootDirectory: string; setNoCacheHeaders: (res: ServerResponse) => void; setStaticCacheHeaders: (res: ServerResponse, searchParams: URLSearchParams) => void; @@ -19,125 +18,6 @@ export interface StaticFileService { ) => boolean; } -export function createStaticFileService({ - escapeHtml, - rootDirectory, - setNoCacheHeaders, - setStaticCacheHeaders, -}: StaticFileServiceDeps): StaticFileService { - function tryServeStatic( - _req: IncomingMessage, - res: ServerResponse, - pathname: string, - searchParams: URLSearchParams, - ): boolean { - const normalizedPath = pathname === '/' ? '/' : pathname.replace(/\/+$/, ''); - const relativePath = normalizedPath.replace(/^\/+/, ''); - const filePath = path.resolve(path.join(rootDirectory, relativePath)); - const relativeToRoot = path.relative(rootDirectory, filePath); - - if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) { - setNoCacheHeaders(res); - res.statusCode = 403; - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.end('Forbidden'); - return true; - } - - if (!fs.existsSync(filePath)) { - return false; - } - - setStaticCacheHeaders(res, searchParams); - - const stat = fs.statSync(filePath); - - if (stat.isDirectory()) { - return sendDirectoryListing(res, pathname, filePath); - } - - if (stat.isFile()) { - return sendStaticFile(res, filePath, stat.size); - } - - return false; - } - - function sendStaticFile(res: ServerResponse, filePath: string, fileSize: number): boolean { - res.statusCode = 200; - res.setHeader('Content-Type', getContentType(filePath)); - res.setHeader('Content-Length', String(fileSize)); - - const stream = fs.createReadStream(filePath); - stream.pipe(res); - - stream.on('error', () => { - if (!res.headersSent) { - res.statusCode = 500; - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - } - if (!res.writableEnded) { - res.end('Internal Server Error'); - } - }); - - return true; - } - - function sendDirectoryListing( - res: ServerResponse, - requestPath: string, - dirPath: string, - ): boolean { - const entries = fs.readdirSync(dirPath, { withFileTypes: true }); - const pathname = requestPath.endsWith('/') ? requestPath : `${requestPath}/`; - - const items: string[] = []; - - if (pathname !== '/') { - const parentPath = pathname - .split('/') - .filter(Boolean) - .slice(0, -1) - .join('/'); - const href = parentPath ? `/${parentPath}/` : '/'; - items.push(`
  • ..
  • `); - } - - entries - .sort((a, b) => a.name.localeCompare(b.name)) - .forEach((entry) => { - const suffix = entry.isDirectory() ? '/' : ''; - const href = `${pathname}${encodeURIComponent(entry.name)}${suffix}`; - items.push(`
  • ${escapeHtml(entry.name)}${suffix}
  • `); - }); - - const html = ` - - - -Index of ${escapeHtml(pathname)} - - -

    Index of ${escapeHtml(pathname)}

    -
      -${items.join('\n')} -
    - -`; - - res.statusCode = 200; - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(html); - - return true; - } - - return { - tryServeStatic, - }; -} - function getContentType(filePath: string): string { const ext = path.extname(filePath).toLowerCase(); @@ -186,3 +66,123 @@ function getContentType(filePath: string): string { return 'application/octet-stream'; } } + +function sendStaticFile(res: ServerResponse, filePath: string, fileSize: number): boolean { + res.statusCode = 200; + res.setHeader('Content-Type', getContentType(filePath)); + res.setHeader('Content-Length', String(fileSize)); + + const stream = fs.createReadStream(filePath); + stream.pipe(res); + + stream.on('error', () => { + if (!res.headersSent) { + res.statusCode = 500; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + } + if (!res.writableEnded) { + res.end('Internal Server Error'); + } + }); + + return true; +} + +function sendDirectoryListing( + res: ServerResponse, + requestPath: string, + dirPath: string, + escapeHtml: (value: string) => string, +): boolean { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + const pathname = requestPath.endsWith('/') ? requestPath : `${requestPath}/`; + + const items: string[] = []; + + if (pathname !== '/') { + const parentPath = pathname + .split('/') + .filter(Boolean) + .slice(0, -1) + .join('/'); + const href = parentPath ? `/${parentPath}/` : '/'; + items.push(`
  • ..
  • `); + } + + entries + .sort((a, b) => a.name.localeCompare(b.name)) + .forEach((entry) => { + const suffix = entry.isDirectory() ? '/' : ''; + const href = `${pathname}${encodeURIComponent(entry.name)}${suffix}`; + items.push(`
  • ${escapeHtml(entry.name)}${suffix}
  • `); + }); + + const html = ` + + + +Index of ${escapeHtml(pathname)} + + +

    Index of ${escapeHtml(pathname)}

    +
      +${items.join('\n')} +
    + +`; + + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(html); + + return true; +} + +export function createStaticFileService({ + escapeHtml, + rootDirectory, + setNoCacheHeaders, + setStaticCacheHeaders, +}: StaticFileServiceDeps): StaticFileService { + function tryServeStatic( + _req: IncomingMessage, + res: ServerResponse, + pathname: string, + searchParams: URLSearchParams, + ): boolean { + const normalizedPath = pathname === '/' ? '/' : pathname.replace(/\/+$/, ''); + const relativePath = normalizedPath.replace(/^\/+/, ''); + const filePath = path.resolve(path.join(rootDirectory, relativePath)); + const relativeToRoot = path.relative(rootDirectory, filePath); + + if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) { + setNoCacheHeaders(res); + res.statusCode = 403; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end('Forbidden'); + return true; + } + + if (!fs.existsSync(filePath)) { + return false; + } + + setStaticCacheHeaders(res, searchParams); + + const stat = fs.statSync(filePath); + + if (stat.isDirectory()) { + return sendDirectoryListing(res, pathname, filePath, escapeHtml); + } + + if (stat.isFile()) { + return sendStaticFile(res, filePath, stat.size); + } + + return false; + } + + return { + tryServeStatic, + }; +} diff --git a/packages/devextreme/testing/runner/lib/suites.ts b/packages/devextreme/testing/runner/lib/suites.ts index 14bd55f76f27..3fc5512b6ece 100644 --- a/packages/devextreme/testing/runner/lib/suites.ts +++ b/packages/devextreme/testing/runner/lib/suites.ts @@ -2,14 +2,21 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { CategoryInfo, RunSuiteModel, SuiteInfo } from './types'; +import { + CategoryInfo, + ConstellationFilter, + ConstellationName, + RunSuiteModel, + SuiteInfo, + isConstellationName, +} from './types'; export interface GetAllSuitesOptions { deviceMode: boolean; - constellation: string; - includeCategories: Set | null; - excludeCategories: Set | null; - excludeSuites: Set | null; + constellation: ConstellationFilter; + includeCategories: ReadonlySet | null; + excludeCategories: ReadonlySet | null; + excludeSuites: ReadonlySet | null; partIndex: number; partCount: number; } @@ -22,23 +29,84 @@ export interface SuitesService { } interface SuitesServiceOptions { - knownConstellations: Set; + knownConstellations: ReadonlySet; testsRoot: string; } +interface CategoryMetaPayload { + constellation?: string | number | boolean; + explicit?: unknown; + runOnDevices?: unknown; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function toPrimitiveString(value: unknown): string { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + + return ''; +} + +function isNotEmptyDir(dirPath: string): boolean { + try { + return fs.readdirSync(dirPath).length > 0; + } catch { + return false; + } +} + +function readCategoryMeta(metaPath: string): CategoryMetaPayload { + const parsed = JSON.parse(fs.readFileSync(metaPath, 'utf8')) as unknown; + + if (!isRecord(parsed)) { + throw new Error(`Invalid test category metadata: ${metaPath}`); + } + + const rawConstellation = parsed.constellation; + const constellationValue = ( + typeof rawConstellation === 'string' + || typeof rawConstellation === 'number' + || typeof rawConstellation === 'boolean' + ) + ? rawConstellation + : undefined; + + return { + constellation: constellationValue, + explicit: parsed.explicit, + runOnDevices: parsed.runOnDevices, + }; +} + +function parseConstellation( + rawValue: unknown, + metaPath: string, + knownConstellations: ReadonlySet, +): ConstellationName { + const constellationValue = toPrimitiveString(rawValue); + + if (!isConstellationName(constellationValue) || !knownConstellations.has(constellationValue)) { + throw new Error(`Unknown constellation (group of categories):${constellationValue} in ${metaPath}`); + } + + return constellationValue; +} + export function createSuitesService({ knownConstellations, testsRoot, }: SuitesServiceOptions): SuitesService { function readCategories(): CategoryInfo[] { - const dirs = fs.readdirSync(testsRoot, { withFileTypes: true }) + return fs.readdirSync(testsRoot, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => path.join(testsRoot, entry.name)) .filter(isNotEmptyDir) .map(categoryFromPath) .sort((a, b) => a.Name.localeCompare(b.Name)); - - return dirs; } function readSuites(catName: string): SuiteInfo[] { @@ -58,12 +126,10 @@ export function createSuitesService({ } }); - const suites = fs.readdirSync(catPath, { withFileTypes: true }) + return fs.readdirSync(catPath, { withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.endsWith('.js')) .map((entry) => suiteFromPath(catName, path.join(catPath, entry.name))) .sort((a, b) => a.ShortName.localeCompare(b.ShortName)); - - return suites; } function getAllSuites({ @@ -75,8 +141,8 @@ export function createSuitesService({ partIndex, partCount, }: GetAllSuitesOptions): SuiteInfo[] { - const includeSpecified = includeCategories && includeCategories.size > 0; - const excludeSpecified = excludeCategories && excludeCategories.size > 0; + const includeSpecified = (includeCategories?.size ?? 0) > 0; + const excludeSpecified = (excludeCategories?.size ?? 0) > 0; const result: SuiteInfo[] = []; readCategories().forEach((category) => { @@ -84,19 +150,19 @@ export function createSuitesService({ return; } - if (constellation && category.Constellation !== constellation) { + if (constellation !== '' && category.Constellation !== constellation) { return; } - if (includeSpecified && !includeCategories.has(category.Name)) { + if (includeSpecified && !includeCategories?.has(category.Name)) { return; } - if (category.Explicit && (!includeSpecified || !includeCategories.has(category.Name))) { + if (category.Explicit && !includeCategories?.has(category.Name)) { return; } - if (excludeSpecified && excludeCategories.has(category.Name)) { + if (excludeSpecified && excludeCategories?.has(category.Name)) { return; } @@ -134,27 +200,11 @@ export function createSuitesService({ function categoryFromPath(categoryPath: string): CategoryInfo { const name = path.basename(categoryPath); const metaPath = path.join(categoryPath, '__meta.json'); - const parsed = JSON.parse(fs.readFileSync(metaPath, 'utf8')) as unknown; - - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error(`Invalid test category metadata: ${metaPath}`); - } - - const meta = parsed as { - constellation?: unknown; - explicit?: unknown; - runOnDevices?: unknown; - }; - - const constellationValue = toPrimitiveString(meta.constellation); - - if (!knownConstellations.has(constellationValue)) { - throw new Error(`Unknown constellation (group of categories):${constellationValue}`); - } + const meta = readCategoryMeta(metaPath); return { Name: name, - Constellation: constellationValue, + Constellation: parseConstellation(meta.constellation, metaPath, knownConstellations), Explicit: Boolean(meta.explicit), RunOnDevices: Boolean(meta.runOnDevices), }; @@ -178,19 +228,3 @@ export function createSuitesService({ readSuites, }; } - -function isNotEmptyDir(dirPath: string): boolean { - try { - return fs.readdirSync(dirPath).length > 0; - } catch { - return false; - } -} - -function toPrimitiveString(value: unknown): string { - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - return String(value); - } - - return ''; -} diff --git a/packages/devextreme/testing/runner/lib/templates.ts b/packages/devextreme/testing/runner/lib/templates.ts index c57a1d4145e9..4331412602c0 100644 --- a/packages/devextreme/testing/runner/lib/templates.ts +++ b/packages/devextreme/testing/runner/lib/templates.ts @@ -1,13 +1,13 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { TemplateVars } from './types'; +import { TemplateVarValue, TemplateVars } from './types'; export interface TemplateRenderer { renderTemplate: (templateName: string, vars?: TemplateVars) => string; } -function stringifyTemplateValue(value: unknown): string { +function stringifyTemplateValue(value: TemplateVarValue): string { if (value === null || value === undefined) { return ''; } @@ -25,15 +25,16 @@ function stringifyTemplateValue(value: unknown): string { export function createTemplateRenderer( templatesRoot: string, - escapeHtml: (value: unknown) => string, + escapeHtml: (value: string) => string, ): TemplateRenderer { const templateCache = new Map(); function readTemplate(templateName: string): string { const key = String(templateName || ''); - if (templateCache.has(key)) { - return templateCache.get(key) as string; + const cached = templateCache.get(key); + if (cached !== undefined) { + return cached; } const filePath = path.resolve(templatesRoot, key); @@ -51,7 +52,7 @@ export function createTemplateRenderer( function getTemplateValue(data: TemplateVars, key: string, shouldEscape: boolean): string { const hasValue = Object.prototype.hasOwnProperty.call(data, key); - const value = hasValue ? data[key] : ''; + const value: TemplateVarValue = hasValue ? data[key] : ''; const valueAsString = stringifyTemplateValue(value); if (shouldEscape) { diff --git a/packages/devextreme/testing/runner/lib/types.ts b/packages/devextreme/testing/runner/lib/types.ts index 248ce42adbcb..1034e6fec6c4 100644 --- a/packages/devextreme/testing/runner/lib/types.ts +++ b/packages/devextreme/testing/runner/lib/types.ts @@ -1,4 +1,27 @@ +export const KNOWN_CONSTELLATION_NAMES = [ + 'export', + 'misc', + 'ui', + 'ui.widgets', + 'ui.editors', + 'ui.grid', + 'ui.scheduler', +] as const; + export type RunnerLogColor = 'red' | 'green' | 'yellow' | 'white'; +export type ConstellationName = (typeof KNOWN_CONSTELLATION_NAMES)[number]; +export type ConstellationFilter = string; +const KNOWN_CONSTELLATIONS_SET = new Set(KNOWN_CONSTELLATION_NAMES); + +export function isConstellationName(value: string): value is ConstellationName { + return KNOWN_CONSTELLATIONS_SET.has(value); +} + +export type JsonPrimitive = string | number | boolean | null; +export interface JsonObject { + [key: string]: JsonValue; +} +export type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; export interface RunnerLogger { write: (message?: string, color?: RunnerLogColor) => void; @@ -20,7 +43,7 @@ export interface BaseRunProps { export interface CategoryInfo { Name: string; - Constellation: string; + Constellation: ConstellationName; Explicit: boolean; RunOnDevices: boolean; } @@ -37,39 +60,39 @@ export interface RunSuiteModel { } export interface RunAllModel { - Constellation: string; + Constellation: ConstellationFilter; CategoriesList: string; Version: string; Suites: SuiteInfo[]; } export interface TestCaseIssue { - message?: string; + message: string; } export interface TestCaseResult { - name?: string; - url?: string; - time?: number | string; - executed?: boolean; - failure?: TestCaseIssue; - reason?: TestCaseIssue; + name: string; + url: string; + time: number; + executed: boolean; + failure: TestCaseIssue | null; + reason: TestCaseIssue | null; } export interface TestSuiteResult { - name?: string; - time?: number | string; - pureTime?: number | string; - results?: TestResultItem[]; + name: string; + time: number; + pureTime: number; + results: TestResultItem[]; } export type TestResultItem = TestSuiteResult | TestCaseResult; export interface TestResultsPayload { - name?: string; - total?: number | string; - failures?: number | string; - suites?: TestSuiteResult[]; + name: string; + total: number; + failures: number; + suites: TestSuiteResult[]; } export interface VectorMapDataItem { @@ -80,9 +103,14 @@ export interface VectorMapDataItem { export interface VectorMapOutputItem { file: string; variable: string | null; - content: unknown; + content: JsonValue; } -export type PortsMap = Record; +export interface PortsMap { + [key: string]: number | string; + qunit: number | string; + 'vectormap-utils-tester': number | string; +} -export type TemplateVars = Record; +export type TemplateVarValue = JsonValue | bigint | undefined; +export type TemplateVars = Record; diff --git a/packages/devextreme/testing/runner/lib/utils.ts b/packages/devextreme/testing/runner/lib/utils.ts index a1bb0d1beb51..48d951828ef9 100644 --- a/packages/devextreme/testing/runner/lib/utils.ts +++ b/packages/devextreme/testing/runner/lib/utils.ts @@ -4,6 +4,14 @@ import * as path from 'node:path'; import { PortsMap } from './types'; +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isPortValue(value: unknown): value is number | string { + return typeof value === 'number' || typeof value === 'string'; +} + export function jsonString(value: unknown): string { return JSON.stringify(value); } @@ -33,11 +41,28 @@ export function escapeXmlAttr(value: unknown): string { export function loadPorts(filePath: string): PortsMap { const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + if (!isRecord(parsed)) { throw new Error(`Invalid ports definition: ${filePath}`); } - return parsed as PortsMap; + if (!isPortValue(parsed.qunit) || !isPortValue(parsed['vectormap-utils-tester'])) { + throw new Error(`Required ports are missing in ${filePath}`); + } + + const portsMap: PortsMap = { + qunit: parsed.qunit, + 'vectormap-utils-tester': parsed['vectormap-utils-tester'], + }; + + Object.entries(parsed).forEach(([key, value]) => { + if (!isPortValue(value)) { + throw new Error(`Invalid port value for "${key}" in ${filePath}`); + } + + portsMap[key] = value; + }); + + return portsMap; } export function safeReadFile(filePath: string): string { diff --git a/packages/devextreme/testing/runner/lib/vectormap.ts b/packages/devextreme/testing/runner/lib/vectormap.ts index 5ca8fd3064e8..30689424c1e9 100644 --- a/packages/devextreme/testing/runner/lib/vectormap.ts +++ b/packages/devextreme/testing/runner/lib/vectormap.ts @@ -1,11 +1,18 @@ -/* eslint-disable @typescript-eslint/no-use-before-define */ -/* eslint-disable no-await-in-loop */ import { ChildProcess, spawn, spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as http from 'node:http'; import * as path from 'node:path'; -import { VectorMapDataItem, VectorMapOutputItem } from './types'; +import { + JsonObject, + JsonValue, + VectorMapDataItem, + VectorMapOutputItem, +} from './types'; + +const VECTOR_SERVER_RETRY_TIMEOUT_MS = 5000; +const VECTOR_SERVER_RETRY_DELAY_MS = 50; +const VECTOR_SERVER_KILL_DELAY_MS = 200; interface VectorMapServiceOptions { packageRoot: string; @@ -28,6 +35,73 @@ interface VectorMapNodeServerState { killTimer: NodeJS.Timeout | null; } +function isJsonObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isJsonValue(value: unknown): value is JsonValue { + if ( + value === null + || typeof value === 'string' + || typeof value === 'number' + || typeof value === 'boolean' + ) { + return true; + } + + if (Array.isArray(value)) { + return value.every((item) => isJsonValue(item)); + } + + if (isJsonObject(value)) { + return Object.values(value).every((item) => isJsonValue(item)); + } + + return false; +} + +function getErrorCode(error: Error): string | null { + if ('code' in error && typeof error.code === 'string') { + return error.code; + } + + return null; +} + +function parseJsonContent(content: string, filePath: string): JsonValue { + const parsed = JSON.parse(content) as unknown; + + if (!isJsonValue(parsed)) { + throw new Error(`Unsupported JSON structure in ${filePath}`); + } + + return parsed; +} + +function wait(timeout: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, timeout); + }); +} + +function httpGetText(targetUrl: string): Promise { + return new Promise((resolve, reject) => { + const request = http.get(targetUrl, (response) => { + const chunks: Buffer[] = []; + + response.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + + response.on('end', () => { + resolve(Buffer.concat(chunks).toString('utf8')); + }); + }); + + request.on('error', reject); + }); +} + export function createVectorMapService({ packageRoot, testingRoot, @@ -79,6 +153,70 @@ export function createVectorMapService({ }); } + function acquireVectorMapNodeServer(): void { + if (vectorMapNodeServer.killTimer !== null) { + clearTimeout(vectorMapNodeServer.killTimer); + vectorMapNodeServer.killTimer = null; + } + + if (vectorMapNodeServer.process === null || vectorMapNodeServer.process.killed) { + const scriptPath = path.join(testingRoot, 'helpers', 'vectormaputils-tester.js'); + + vectorMapNodeServer.process = spawn( + pathToNode, + [scriptPath, `${vectorDataDirectory}${path.sep}`], + { + stdio: 'ignore', + }, + ); + + vectorMapNodeServer.process.on('exit', () => { + if (vectorMapNodeServer.process !== null && vectorMapNodeServer.process.exitCode !== null) { + vectorMapNodeServer.process = null; + } + }); + } + + vectorMapNodeServer.refs += 1; + } + + function releaseVectorMapNodeServer(): void { + vectorMapNodeServer.refs -= 1; + + if (vectorMapNodeServer.refs <= 0) { + vectorMapNodeServer.refs = 0; + + vectorMapNodeServer.killTimer = setTimeout(() => { + if (vectorMapNodeServer.refs === 0 && vectorMapNodeServer.process !== null) { + try { + vectorMapNodeServer.process.kill(); + } catch { + // Ignore process kill failures. + } + vectorMapNodeServer.process = null; + } + vectorMapNodeServer.killTimer = null; + }, VECTOR_SERVER_KILL_DELAY_MS); + } + } + + async function requestWithRetryUntilReady( + action: string, + arg: string, + startTime: number, + ): Promise { + try { + return await httpGetText(`http://127.0.0.1:${vectorMapTesterPort}/${action}/${arg}`); + } catch (error) { + if (Date.now() - startTime > VECTOR_SERVER_RETRY_TIMEOUT_MS) { + throw error; + } + + await wait(VECTOR_SERVER_RETRY_DELAY_MS); + return requestWithRetryUntilReady(action, arg, startTime); + } + } + async function redirectRequestToVectorMapNodeServer( action: string, arg: string, @@ -86,20 +224,7 @@ export function createVectorMapService({ acquireVectorMapNodeServer(); try { - const startTime = Date.now(); - - while (true) { - try { - const text = await httpGetText(`http://127.0.0.1:${vectorMapTesterPort}/${action}/${arg}`); - return text; - } catch(error) { - if (Date.now() - startTime > 5000) { - throw error; - } - - await wait(50); - } - } + return await requestWithRetryUntilReady(action, arg, Date.now()); } finally { releaseVectorMapNodeServer(); } @@ -116,15 +241,14 @@ export function createVectorMapService({ const vectorMapUtilsNodePath = path.resolve(path.join(packageRoot, 'artifacts/js/vectormap-utils/dx.vectormaputils.node.js')); const args = [vectorMapUtilsNodePath, inputDirectory]; - - if (searchParams.has('file')) { - args[1] += searchParams.get('file'); + const fileArgument = searchParams.get('file'); + if (fileArgument !== null) { + args[1] += fileArgument; } args.push('--quiet', '--output', outputDirectory, '--settings', settingsPath, '--process-file-content', processFileContentPath); const isJson = searchParams.has('json'); - if (isJson) { args.push('--json'); } @@ -137,13 +261,10 @@ export function createVectorMapService({ stdio: 'ignore', }); - const spawnError = spawnResult.error as (Error & { code?: string }) | undefined; - - if (spawnError) { - if (spawnError.code === 'ETIMEDOUT') { - // Intentionally ignored to match legacy behavior. - } else { - throw spawnError; + if (spawnResult.error !== undefined) { + const errorCode = getErrorCode(spawnResult.error); + if (errorCode !== 'ETIMEDOUT') { + throw spawnResult.error; } } @@ -171,7 +292,7 @@ export function createVectorMapService({ return { file: `${path.basename(entry.name, extension)}${extension}`, variable, - content: JSON.parse(text), + content: parseJsonContent(text, filePath), }; }); } finally { @@ -183,53 +304,6 @@ export function createVectorMapService({ } } - function acquireVectorMapNodeServer(): void { - if (vectorMapNodeServer.killTimer) { - clearTimeout(vectorMapNodeServer.killTimer); - vectorMapNodeServer.killTimer = null; - } - - if (!vectorMapNodeServer.process || vectorMapNodeServer.process.killed) { - const scriptPath = path.join(testingRoot, 'helpers', 'vectormaputils-tester.js'); - - vectorMapNodeServer.process = spawn( - pathToNode, - [scriptPath, `${vectorDataDirectory}${path.sep}`], - { - stdio: 'ignore', - }, - ); - - vectorMapNodeServer.process.on('exit', () => { - if (vectorMapNodeServer.process && vectorMapNodeServer.process.exitCode !== null) { - vectorMapNodeServer.process = null; - } - }); - } - - vectorMapNodeServer.refs += 1; - } - - function releaseVectorMapNodeServer(): void { - vectorMapNodeServer.refs -= 1; - - if (vectorMapNodeServer.refs <= 0) { - vectorMapNodeServer.refs = 0; - - vectorMapNodeServer.killTimer = setTimeout(() => { - if (vectorMapNodeServer.refs === 0 && vectorMapNodeServer.process) { - try { - vectorMapNodeServer.process.kill(); - } catch { - // Ignore process kill failures. - } - vectorMapNodeServer.process = null; - } - vectorMapNodeServer.killTimer = null; - }, 200); - } - } - return { executeVectorMapConsoleApp, readThemeCssFiles, @@ -237,27 +311,3 @@ export function createVectorMapService({ redirectRequestToVectorMapNodeServer, }; } - -function httpGetText(targetUrl: string): Promise { - return new Promise((resolve, reject) => { - const request = http.get(targetUrl, (response) => { - const chunks: Buffer[] = []; - - response.on('data', (chunk: Buffer | string) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - - response.on('end', () => { - resolve(Buffer.concat(chunks).toString('utf8')); - }); - }); - - request.on('error', reject); - }); -} - -function wait(timeout: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, timeout); - }); -} From 6fbc9701b94ccbb07bc7ebf9179cda8a992fa379 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:28:09 +0200 Subject: [PATCH 11/11] Fix CI detection operator and remove unused parameter in Node runner (#32809) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nightskylark <2280467+nightskylark@users.noreply.github.com> --- packages/devextreme/testing/runner/index.ts | 3 +-- packages/devextreme/testing/runner/lib/utils.ts | 2 +- packages/devextreme/testing/runner/lib/vectormap.ts | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/devextreme/testing/runner/index.ts b/packages/devextreme/testing/runner/index.ts index 547f3985cfd3..29a4a0d6652f 100644 --- a/packages/devextreme/testing/runner/index.ts +++ b/packages/devextreme/testing/runner/index.ts @@ -335,8 +335,7 @@ async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse if (req.method === 'GET') { const executeConsoleAppMatch = /^\/TestVectorMapData\/ExecuteConsoleApp(?:\/(.*))?$/i.exec(pathname); if (executeConsoleAppMatch) { - const arg = safeDecodeURIComponent(executeConsoleAppMatch[1] || ''); - const result = vectorMapService.executeVectorMapConsoleApp(arg, requestUrl.searchParams); + const result = vectorMapService.executeVectorMapConsoleApp(requestUrl.searchParams); sendJson(res, result); return; } diff --git a/packages/devextreme/testing/runner/lib/utils.ts b/packages/devextreme/testing/runner/lib/utils.ts index 48d951828ef9..ec6e230f36a1 100644 --- a/packages/devextreme/testing/runner/lib/utils.ts +++ b/packages/devextreme/testing/runner/lib/utils.ts @@ -114,7 +114,7 @@ export function formatDateForSuiteTimestamp(date: Date): string { } export function isContinuousIntegration(): boolean { - return Boolean(process.env.CCNetWorkingDirectory ?? process.env.DEVEXTREME_TEST_CI); + return Boolean(process.env.CCNetWorkingDirectory || process.env.DEVEXTREME_TEST_CI); } export function resolveNodePath(): string { diff --git a/packages/devextreme/testing/runner/lib/vectormap.ts b/packages/devextreme/testing/runner/lib/vectormap.ts index 30689424c1e9..4b490694fb5a 100644 --- a/packages/devextreme/testing/runner/lib/vectormap.ts +++ b/packages/devextreme/testing/runner/lib/vectormap.ts @@ -23,7 +23,7 @@ interface VectorMapServiceOptions { } export interface VectorMapService { - executeVectorMapConsoleApp: (arg: string, searchParams: URLSearchParams) => VectorMapOutputItem[]; + executeVectorMapConsoleApp: (searchParams: URLSearchParams) => VectorMapOutputItem[]; readThemeCssFiles: () => string[]; readVectorMapTestData: () => VectorMapDataItem[]; redirectRequestToVectorMapNodeServer: (action: string, arg: string) => Promise; @@ -231,7 +231,6 @@ export function createVectorMapService({ } function executeVectorMapConsoleApp( - arg: string, searchParams: URLSearchParams, ): VectorMapOutputItem[] { const inputDirectory = `${path.join(packageRoot, 'testing', 'content', 'VectorMapData')}${path.sep}`;