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/docker-ci.sh b/packages/devextreme/docker-ci.sh index d6edd0bcbb33..21c9bd66439e 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,11 @@ 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 "Compiling TypeScript test runner..." + pnpm exec tsc -p ./testing/runner/tsconfig.json + + echo "Starting Node.js test runner..." + node ./testing/runner/dist/index.js --single-run & runner_pid=$! echo "Runner PID: $runner_pid" local max_attempts=30 @@ -241,7 +244,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 735d0ec383d1..b9385c6c3714 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/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/launch b/packages/devextreme/testing/launch index 04e9421f6c32..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( - 'dotnet', - [ join(__dirname, 'runner/bin/runner.dll') ], + 'node', + [ 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/.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/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/index.ts b/packages/devextreme/testing/runner/index.ts new file mode 100644 index 000000000000..29a4a0d6652f --- /dev/null +++ b/packages/devextreme/testing/runner/index.ts @@ -0,0 +1,499 @@ +#!/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, + ConstellationFilter, + ConstellationName, + KNOWN_CONSTELLATION_NAMES, + RunAllModel, + TestResultsPayload, +} from './lib/types'; + +const KNOWN_CONSTELLATIONS = new Set(KNOWN_CONSTELLATION_NAMES); + +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 result = vectorMapService.executeVectorMapConsoleApp(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: ConstellationFilter = 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.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); + } + + return { + Constellation: constellation, + CategoriesList: include ?? '', + Version: readPackageVersion(), + Suites: suitesService.getAllSuites({ + deviceMode: hasDeviceModeFlag(searchParams), + constellation, + includeCategories: includeSet, + excludeCategories: excludeSet, + excludeSuites, + partIndex, + partCount, + }), + }; +} + +function assignBaseRunProps(searchParams: URLSearchParams): BaseRunProps { + const maxWorkersRaw = process.env.MAX_WORKERS; + + 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 (typeof maxWorkersRaw === 'string' && /^\d+$/.test(maxWorkersRaw)) { + result.MaxWorkers = Number(maxWorkersRaw); + } + + 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: TestResultsPayload = resultsReporter.parseResultsJson(json); + hasFailure = 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 ''; +} + +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/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.ts b/packages/devextreme/testing/runner/lib/logger.ts new file mode 100644 index 000000000000..79460e4aeb12 --- /dev/null +++ b/packages/devextreme/testing/runner/lib/logger.ts @@ -0,0 +1,86 @@ +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; +} + +const COLOR_CODES: Readonly> = { + red: 31, + green: 32, + yellow: 33, + white: 37, +}; + +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 { + const logger: RawLogger = { + filePath, + shouldWriteTimePrefix: true, + writeLine(text = '') { + this.write(`${text}\r\n`); + this.shouldWriteTimePrefix = true; + }, + write(text = '') { + if (text.length === 0) { + return; + } + + if (this.shouldWriteTimePrefix) { + this.shouldWriteTimePrefix = false; + fs.appendFileSync(this.filePath, `${formatLogTime(new Date())} `, 'utf8'); + } + + fs.appendFileSync(this.filePath, text, 'utf8'); + }, + }; + + return logger; +} + +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`); + }, + }; +} 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.ts b/packages/devextreme/testing/runner/lib/results.ts new file mode 100644 index 000000000000..ad56e1756d64 --- /dev/null +++ b/packages/devextreme/testing/runner/lib/results.ts @@ -0,0 +1,300 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import { + RunnerLogColor, + TestCaseIssue, + TestCaseResult, + TestResultItem, + TestResultsPayload, + TestSuiteResult, +} from './types'; + +interface ResultsReporterDeps { + 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, + ) => void; + testResultsToXml: (results: TestResultsPayload) => string; + 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, + 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 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, + ): void { + const maxWrittenFailures = 50; + const notRunCases: TestCaseResult[] = []; + const failedCases: TestCaseResult[] = []; + + results.suites.forEach((suite) => { + enumerateAllCases(suite, (testCase) => { + if (testCase.reason !== null) { + notRunCases.push(testCase); + } + if (testCase.failure !== null) { + failedCases.push(testCase); + } + }); + }); + + const { total, failures } = results; + 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 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(timeValue)}"`, + ]; + + if (!testCase.executed) { + attributes.push('executed="false"'); + } + + const hasFailure = testCase.failure !== null; + const hasReason = testCase.reason !== null; + + 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 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, + }; +} diff --git a/packages/devextreme/testing/runner/lib/static.ts b/packages/devextreme/testing/runner/lib/static.ts new file mode 100644 index 000000000000..700f513bc99c --- /dev/null +++ b/packages/devextreme/testing/runner/lib/static.ts @@ -0,0 +1,188 @@ +import * as fs from 'node:fs'; +import { IncomingMessage, ServerResponse } from 'node:http'; +import * as path from 'node:path'; + +interface StaticFileServiceDeps { + escapeHtml: (value: string) => 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; +} + +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'; + } +} + +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 new file mode 100644 index 000000000000..3fc5512b6ece --- /dev/null +++ b/packages/devextreme/testing/runner/lib/suites.ts @@ -0,0 +1,230 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { + CategoryInfo, + ConstellationFilter, + ConstellationName, + RunSuiteModel, + SuiteInfo, + isConstellationName, +} from './types'; + +export interface GetAllSuitesOptions { + deviceMode: boolean; + constellation: ConstellationFilter; + includeCategories: ReadonlySet | null; + excludeCategories: ReadonlySet | null; + excludeSuites: ReadonlySet | 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: 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[] { + 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)); + } + + 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)}`); + } + }); + + 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)); + } + + function getAllSuites({ + deviceMode, + constellation, + includeCategories, + excludeCategories, + excludeSuites, + partIndex, + partCount, + }: GetAllSuitesOptions): SuiteInfo[] { + const includeSpecified = (includeCategories?.size ?? 0) > 0; + const excludeSpecified = (excludeCategories?.size ?? 0) > 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 && !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 meta = readCategoryMeta(metaPath); + + return { + Name: name, + Constellation: parseConstellation(meta.constellation, metaPath, knownConstellations), + 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, + }; +} diff --git a/packages/devextreme/testing/runner/lib/templates.ts b/packages/devextreme/testing/runner/lib/templates.ts new file mode 100644 index 000000000000..4331412602c0 --- /dev/null +++ b/packages/devextreme/testing/runner/lib/templates.ts @@ -0,0 +1,77 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { TemplateVarValue, TemplateVars } from './types'; + +export interface TemplateRenderer { + renderTemplate: (templateName: string, vars?: TemplateVars) => string; +} + +function stringifyTemplateValue(value: TemplateVarValue): 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: string) => string, +): TemplateRenderer { + const templateCache = new Map(); + + function readTemplate(templateName: string): string { + const key = String(templateName || ''); + + const cached = templateCache.get(key); + if (cached !== undefined) { + return cached; + } + + 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: TemplateVarValue = 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..1034e6fec6c4 --- /dev/null +++ b/packages/devextreme/testing/runner/lib/types.ts @@ -0,0 +1,116 @@ +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; + 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: ConstellationName; + Explicit: boolean; + RunOnDevices: boolean; +} + +export interface SuiteInfo { + ShortName: string; + FullName: string; + Url: string; +} + +export interface RunSuiteModel { + Title: string; + ScriptVirtualPath: string; +} + +export interface RunAllModel { + Constellation: ConstellationFilter; + CategoriesList: string; + Version: string; + Suites: SuiteInfo[]; +} + +export interface TestCaseIssue { + message: string; +} + +export interface TestCaseResult { + name: string; + url: string; + time: number; + executed: boolean; + failure: TestCaseIssue | null; + reason: TestCaseIssue | null; +} + +export interface TestSuiteResult { + name: string; + time: number; + pureTime: number; + results: TestResultItem[]; +} + +export type TestResultItem = TestSuiteResult | TestCaseResult; + +export interface TestResultsPayload { + name: string; + total: number; + failures: number; + suites: TestSuiteResult[]; +} + +export interface VectorMapDataItem { + name: string; + expected: string; +} + +export interface VectorMapOutputItem { + file: string; + variable: string | null; + content: JsonValue; +} + +export interface PortsMap { + [key: string]: number | string; + qunit: number | string; + 'vectormap-utils-tester': number | string; +} + +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 new file mode 100644 index 000000000000..ec6e230f36a1 --- /dev/null +++ b/packages/devextreme/testing/runner/lib/utils.ts @@ -0,0 +1,176 @@ +import * as fs from 'node:fs'; +import { IncomingMessage } from 'node:http'; +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); +} + +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 (!isRecord(parsed)) { + throw new Error(`Invalid ports definition: ${filePath}`); + } + + 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 { + 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.ts b/packages/devextreme/testing/runner/lib/vectormap.ts new file mode 100644 index 000000000000..4b490694fb5a --- /dev/null +++ b/packages/devextreme/testing/runner/lib/vectormap.ts @@ -0,0 +1,312 @@ +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 { + 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; + testingRoot: string; + vectorDataDirectory: string; + vectorMapTesterPort: number; + pathToNode: string; +} + +export interface VectorMapService { + executeVectorMapConsoleApp: (searchParams: URLSearchParams) => VectorMapOutputItem[]; + readThemeCssFiles: () => string[]; + readVectorMapTestData: () => VectorMapDataItem[]; + redirectRequestToVectorMapNodeServer: (action: string, arg: string) => Promise; +} + +interface VectorMapNodeServerState { + process: ChildProcess | null; + refs: number; + 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, + 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'), + }; + }); + } + + 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, + ): Promise { + acquireVectorMapNodeServer(); + + try { + return await requestWithRetryUntilReady(action, arg, Date.now()); + } finally { + releaseVectorMapNodeServer(); + } + } + + function executeVectorMapConsoleApp( + 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]; + 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'); + } + + fs.mkdirSync(outputDirectory, { recursive: true }); + + try { + const spawnResult = spawnSync(pathToNode, args, { + timeout: 15000, + stdio: 'ignore', + }); + + if (spawnResult.error !== undefined) { + const errorCode = getErrorCode(spawnResult.error); + if (errorCode !== 'ETIMEDOUT') { + 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: 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: parseJsonContent(text, filePath), + }; + }); + } finally { + try { + fs.rmSync(outputDirectory, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors. + } + } + } + + return { + executeVectorMapConsoleApp, + readThemeCssFiles, + readVectorMapTestData, + redirectRequestToVectorMapNodeServer, + }; +} 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 - - - diff --git a/packages/devextreme/testing/runner/Views/Main/Index.cshtml b/packages/devextreme/testing/runner/templates/index.template.html similarity index 94% rename from packages/devextreme/testing/runner/Views/Main/Index.cshtml rename to packages/devextreme/testing/runner/templates/index.template.html index 6b4ba105e046..2ffc4ec3a064 100644 --- a/packages/devextreme/testing/runner/Views/Main/Index.cshtml +++ b/packages/devextreme/testing/runner/templates/index.template.html @@ -1,7 +1,7 @@ - - + + - + + + + + + + + + + + + + + + + + + +
    +
    + + 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" ] }